feat: typed script parameters with validation and icon-grid selector
- Add ScriptParameterConfig model (string, integer, float, boolean, select types) - Server-side validation at both define-time and execute-time - Parameters passed as SCRIPT_PARAM_* environment variables - Web UI parameter editor in script create/edit dialog (add/remove/reorder) - Icon-grid selector component (ported from wled-screen-controller) - Replace audio device dropdown with icon-grid selector - Replace callback event dropdown with icon-grid selector - Localization for parameter UI (en, ru)
This commit is contained in:
@@ -8,6 +8,8 @@ import {
|
||||
scripts, setScripts,
|
||||
getAuthHeaders, hasCredentials,
|
||||
} from './core.js';
|
||||
import { IconSelect } from './icon-select.js';
|
||||
import { paramTypeIcons } from './icons.js';
|
||||
|
||||
export let scriptFormDirty = false;
|
||||
export function setScriptFormDirty(value) { scriptFormDirty = value; }
|
||||
@@ -119,28 +121,219 @@ export async function displayQuickAccess() {
|
||||
resolveMdiIcons(grid);
|
||||
}
|
||||
|
||||
async function executeScript(scriptName, buttonElement) {
|
||||
buttonElement.classList.add('executing');
|
||||
function _getScriptParams(scriptName) {
|
||||
const script = scripts.find(s => s.name === scriptName);
|
||||
return (script && script.parameters) ? script.parameters : {};
|
||||
}
|
||||
|
||||
async function executeScript(scriptName, buttonElement) {
|
||||
const paramDefs = _getScriptParams(scriptName);
|
||||
if (Object.keys(paramDefs).length > 0) {
|
||||
_showParamsInputDialog(scriptName, paramDefs, async (params) => {
|
||||
buttonElement.classList.add('executing');
|
||||
try {
|
||||
await _doExecuteScript(scriptName, params);
|
||||
} finally {
|
||||
buttonElement.classList.remove('executing');
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
buttonElement.classList.add('executing');
|
||||
try {
|
||||
await _doExecuteScript(scriptName, {});
|
||||
} finally {
|
||||
buttonElement.classList.remove('executing');
|
||||
}
|
||||
}
|
||||
|
||||
async function _doExecuteScript(scriptName, params) {
|
||||
try {
|
||||
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ args: [] })
|
||||
body: JSON.stringify({ params })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
showToast(`${scriptName} executed successfully`, 'success');
|
||||
showToast(t('scripts.msg.executed', { name: scriptName }), 'success');
|
||||
} else {
|
||||
showToast(`Failed to execute ${scriptName}`, 'error');
|
||||
showToast(result.detail || t('scripts.msg.execute_failed', { name: scriptName }), 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error executing script ${scriptName}:`, error);
|
||||
showToast(`Error executing ${scriptName}`, 'error');
|
||||
} finally {
|
||||
buttonElement.classList.remove('executing');
|
||||
showToast(t('scripts.msg.execute_error', { name: scriptName }), 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Script Parameters Input Dialog (execution-time)
|
||||
// ============================================================
|
||||
|
||||
let _paramsCallback = null;
|
||||
let _paramsScriptName = null;
|
||||
let _paramsIconSelects = null;
|
||||
|
||||
function _showParamsInputDialog(scriptName, paramDefs, callback) {
|
||||
_paramsCallback = callback;
|
||||
_paramsScriptName = scriptName;
|
||||
|
||||
const dialog = document.getElementById('scriptParamsDialog');
|
||||
const title = document.getElementById('scriptParamsDialogTitle');
|
||||
const container = document.getElementById('scriptParamsInputs');
|
||||
|
||||
const script = scripts.find(s => s.name === scriptName);
|
||||
title.textContent = script ? (script.label || scriptName) : scriptName;
|
||||
container.innerHTML = '';
|
||||
|
||||
// Track IconSelect instances for cleanup
|
||||
if (!_paramsIconSelects) _paramsIconSelects = [];
|
||||
|
||||
for (const [pname, pdef] of Object.entries(paramDefs)) {
|
||||
const wrapper = document.createElement('label');
|
||||
|
||||
const labelText = document.createElement('span');
|
||||
labelText.textContent = pname + (pdef.required ? ' *' : '');
|
||||
wrapper.appendChild(labelText);
|
||||
|
||||
if (pdef.description) {
|
||||
const hint = document.createElement('small');
|
||||
hint.className = 'param-hint';
|
||||
hint.textContent = pdef.description;
|
||||
wrapper.appendChild(hint);
|
||||
}
|
||||
|
||||
let input;
|
||||
if (pdef.type === 'select' && pdef.options) {
|
||||
input = document.createElement('select');
|
||||
if (!pdef.required) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = '';
|
||||
opt.textContent = '—';
|
||||
input.appendChild(opt);
|
||||
}
|
||||
for (const optVal of pdef.options) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = optVal;
|
||||
opt.textContent = optVal;
|
||||
if (pdef.default !== undefined && pdef.default !== null && String(pdef.default) === optVal) {
|
||||
opt.selected = true;
|
||||
}
|
||||
input.appendChild(opt);
|
||||
}
|
||||
input.dataset.paramName = pname;
|
||||
input.dataset.paramType = pdef.type;
|
||||
if (pdef.required) input.required = true;
|
||||
wrapper.appendChild(input);
|
||||
|
||||
// Enhance with icon grid if few options
|
||||
if (pdef.options.length <= 10) {
|
||||
const selItems = pdef.options.map(o => ({ value: o, icon: '', label: o }));
|
||||
const cols = Math.min(pdef.options.length, 4);
|
||||
const isel = new IconSelect({ target: input, items: selItems, columns: cols });
|
||||
_paramsIconSelects.push(isel);
|
||||
}
|
||||
} else if (pdef.type === 'boolean') {
|
||||
const boolSvgTrue = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>';
|
||||
const boolSvgFalse = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>';
|
||||
|
||||
input = document.createElement('select');
|
||||
const optTrue = document.createElement('option');
|
||||
optTrue.value = 'true';
|
||||
optTrue.textContent = 'true';
|
||||
const optFalse = document.createElement('option');
|
||||
optFalse.value = 'false';
|
||||
optFalse.textContent = 'false';
|
||||
input.appendChild(optTrue);
|
||||
input.appendChild(optFalse);
|
||||
if (pdef.default !== undefined && pdef.default !== null) {
|
||||
input.value = String(pdef.default);
|
||||
}
|
||||
input.dataset.paramName = pname;
|
||||
input.dataset.paramType = pdef.type;
|
||||
if (pdef.required) input.required = true;
|
||||
wrapper.appendChild(input);
|
||||
|
||||
// Enhance with icon grid
|
||||
const isel = new IconSelect({
|
||||
target: input,
|
||||
items: [
|
||||
{ value: 'true', icon: boolSvgTrue, label: 'True' },
|
||||
{ value: 'false', icon: boolSvgFalse, label: 'False' },
|
||||
],
|
||||
columns: 2,
|
||||
});
|
||||
_paramsIconSelects.push(isel);
|
||||
} else if (pdef.type === 'integer' || pdef.type === 'float') {
|
||||
input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
if (pdef.type === 'float') input.step = 'any';
|
||||
if (pdef.min !== undefined && pdef.min !== null) input.min = pdef.min;
|
||||
if (pdef.max !== undefined && pdef.max !== null) input.max = pdef.max;
|
||||
if (pdef.default !== undefined && pdef.default !== null) input.value = pdef.default;
|
||||
input.dataset.paramName = pname;
|
||||
input.dataset.paramType = pdef.type;
|
||||
if (pdef.required) input.required = true;
|
||||
wrapper.appendChild(input);
|
||||
} else {
|
||||
input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
if (pdef.default !== undefined && pdef.default !== null) input.value = pdef.default;
|
||||
input.dataset.paramName = pname;
|
||||
input.dataset.paramType = pdef.type;
|
||||
if (pdef.required) input.required = true;
|
||||
wrapper.appendChild(input);
|
||||
}
|
||||
|
||||
container.appendChild(wrapper);
|
||||
}
|
||||
|
||||
document.body.classList.add('dialog-open');
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
export function closeScriptParamsDialog() {
|
||||
const dialog = document.getElementById('scriptParamsDialog');
|
||||
_paramsCallback = null;
|
||||
_paramsScriptName = null;
|
||||
// Destroy icon selects from execution dialog
|
||||
if (_paramsIconSelects) {
|
||||
_paramsIconSelects.forEach(isel => isel.destroy());
|
||||
_paramsIconSelects = null;
|
||||
}
|
||||
closeDialog(dialog);
|
||||
document.body.classList.remove('dialog-open');
|
||||
}
|
||||
|
||||
export async function submitScriptWithParams(event) {
|
||||
event.preventDefault();
|
||||
const container = document.getElementById('scriptParamsInputs');
|
||||
const inputs = container.querySelectorAll('[data-param-name]');
|
||||
const params = {};
|
||||
|
||||
for (const input of inputs) {
|
||||
const name = input.dataset.paramName;
|
||||
const type = input.dataset.paramType;
|
||||
let val = input.value;
|
||||
|
||||
if (val === '' && !input.required) continue;
|
||||
if (val === '') continue;
|
||||
|
||||
if (type === 'integer') val = parseInt(val, 10);
|
||||
else if (type === 'float') val = parseFloat(val);
|
||||
else if (type === 'boolean') val = val === 'true';
|
||||
|
||||
params[name] = val;
|
||||
}
|
||||
|
||||
const callback = _paramsCallback;
|
||||
closeScriptParamsDialog();
|
||||
|
||||
if (callback) {
|
||||
await callback(params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +407,7 @@ export function showAddScriptDialog() {
|
||||
document.getElementById('scriptIsEdit').value = 'false';
|
||||
document.getElementById('scriptName').disabled = false;
|
||||
document.getElementById('scriptIconPreview').innerHTML = '';
|
||||
document.getElementById('scriptParamsContainer').innerHTML = '';
|
||||
title.textContent = t('scripts.dialog.add');
|
||||
|
||||
scriptFormDirty = false;
|
||||
@@ -260,6 +454,15 @@ export async function showEditScriptDialog(scriptName) {
|
||||
preview.innerHTML = '';
|
||||
}
|
||||
|
||||
// Populate parameters
|
||||
const paramsContainer = document.getElementById('scriptParamsContainer');
|
||||
paramsContainer.innerHTML = '';
|
||||
if (script.parameters) {
|
||||
for (const [pname, pdef] of Object.entries(script.parameters)) {
|
||||
_addParameterRowWithData(pname, pdef);
|
||||
}
|
||||
}
|
||||
|
||||
title.textContent = t('scripts.dialog.edit');
|
||||
scriptFormDirty = false;
|
||||
|
||||
@@ -301,7 +504,8 @@ export async function saveScript(event) {
|
||||
description: document.getElementById('scriptDescription').value || '',
|
||||
icon: document.getElementById('scriptIcon').value || null,
|
||||
timeout: parseInt(document.getElementById('scriptTimeout').value) || 30,
|
||||
shell: true
|
||||
shell: true,
|
||||
parameters: _collectParameterDefinitions(),
|
||||
};
|
||||
|
||||
const endpoint = isEdit ?
|
||||
@@ -425,6 +629,15 @@ function showExecutionResult(name, result, type = 'script') {
|
||||
}
|
||||
|
||||
export async function executeScriptDebug(scriptName) {
|
||||
const paramDefs = _getScriptParams(scriptName);
|
||||
if (Object.keys(paramDefs).length > 0) {
|
||||
_showParamsInputDialog(scriptName, paramDefs, (params) => _executeScriptDebugWithParams(scriptName, params));
|
||||
return;
|
||||
}
|
||||
await _executeScriptDebugWithParams(scriptName, {});
|
||||
}
|
||||
|
||||
async function _executeScriptDebugWithParams(scriptName, params) {
|
||||
const dialog = document.getElementById('executionDialog');
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
const statusDiv = document.getElementById('executionStatus');
|
||||
@@ -445,7 +658,7 @@ export async function executeScriptDebug(scriptName) {
|
||||
const response = await fetch(`/api/scripts/execute/${scriptName}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
body: JSON.stringify({ args: [] })
|
||||
body: JSON.stringify({ params })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
@@ -471,6 +684,130 @@ export async function executeScriptDebug(scriptName) {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Parameter Definition Editor (CRUD dialog)
|
||||
// ============================================================
|
||||
|
||||
const PARAM_TYPES = ['string', 'integer', 'float', 'boolean', 'select'];
|
||||
|
||||
export function addParameterRow() {
|
||||
_addParameterRowWithData('', {});
|
||||
scriptFormDirty = true;
|
||||
}
|
||||
|
||||
const _paramTypeItems = PARAM_TYPES.map(pt => ({
|
||||
value: pt,
|
||||
icon: paramTypeIcons[pt] || '',
|
||||
label: pt.charAt(0).toUpperCase() + pt.slice(1),
|
||||
}));
|
||||
|
||||
function _addParameterRowWithData(name, def) {
|
||||
const container = document.getElementById('scriptParamsContainer');
|
||||
const row = document.createElement('div');
|
||||
row.className = 'param-row';
|
||||
|
||||
row.innerHTML = `
|
||||
<div class="param-row-header">
|
||||
<input type="text" class="param-name" value="${escapeHtml(name)}"
|
||||
placeholder="${t('scripts.params.name_placeholder')}" pattern="[a-zA-Z][a-zA-Z0-9_]*">
|
||||
<select class="param-type">
|
||||
${PARAM_TYPES.map(pt => `<option value="${pt}" ${def.type === pt ? 'selected' : ''}>${pt}</option>`).join('')}
|
||||
</select>
|
||||
<label class="param-required-label" title="${t('scripts.params.required')}">
|
||||
<input type="checkbox" class="param-required" ${def.required ? 'checked' : ''}>
|
||||
<span>*</span>
|
||||
</label>
|
||||
<button type="button" class="param-remove-btn" title="${t('scripts.params.remove')}">×</button>
|
||||
</div>
|
||||
<div class="param-row-details">
|
||||
<input type="text" class="param-description" value="${escapeHtml(def.description || '')}"
|
||||
placeholder="${t('scripts.params.description_placeholder')}">
|
||||
<div class="param-row-extra">
|
||||
<input type="text" class="param-default" value="${def.default !== undefined && def.default !== null ? escapeHtml(String(def.default)) : ''}"
|
||||
placeholder="${t('scripts.params.default_placeholder')}">
|
||||
<input type="text" class="param-min" value="${def.min !== undefined && def.min !== null ? def.min : ''}"
|
||||
placeholder="Min" style="display:${def.type === 'integer' || def.type === 'float' ? '' : 'none'}">
|
||||
<input type="text" class="param-max" value="${def.max !== undefined && def.max !== null ? def.max : ''}"
|
||||
placeholder="Max" style="display:${def.type === 'integer' || def.type === 'float' ? '' : 'none'}">
|
||||
<input type="text" class="param-options" value="${def.options ? def.options.join(', ') : ''}"
|
||||
placeholder="${t('scripts.params.options_placeholder')}" style="display:${def.type === 'select' ? '' : 'none'}">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Enhance the type <select> with icon grid
|
||||
const typeSelect = row.querySelector('.param-type');
|
||||
const iconSelect = new IconSelect({
|
||||
target: typeSelect,
|
||||
items: _paramTypeItems,
|
||||
columns: 5,
|
||||
onChange: () => {
|
||||
const isNumeric = typeSelect.value === 'integer' || typeSelect.value === 'float';
|
||||
const isSelect = typeSelect.value === 'select';
|
||||
row.querySelector('.param-min').style.display = isNumeric ? '' : 'none';
|
||||
row.querySelector('.param-max').style.display = isNumeric ? '' : 'none';
|
||||
row.querySelector('.param-options').style.display = isSelect ? '' : 'none';
|
||||
scriptFormDirty = true;
|
||||
},
|
||||
});
|
||||
|
||||
row.querySelector('.param-remove-btn').addEventListener('click', () => {
|
||||
iconSelect.destroy();
|
||||
row.remove();
|
||||
scriptFormDirty = true;
|
||||
});
|
||||
|
||||
// Mark dirty on any input change
|
||||
row.querySelectorAll('input').forEach(el => {
|
||||
el.addEventListener('input', () => { scriptFormDirty = true; });
|
||||
});
|
||||
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
function _collectParameterDefinitions() {
|
||||
const container = document.getElementById('scriptParamsContainer');
|
||||
const rows = container.querySelectorAll('.param-row');
|
||||
const params = {};
|
||||
|
||||
for (const row of rows) {
|
||||
const name = row.querySelector('.param-name').value.trim();
|
||||
if (!name) continue;
|
||||
|
||||
const type = row.querySelector('.param-type').value;
|
||||
const def = { type };
|
||||
|
||||
const description = row.querySelector('.param-description').value.trim();
|
||||
if (description) def.description = description;
|
||||
|
||||
if (row.querySelector('.param-required').checked) def.required = true;
|
||||
|
||||
const defaultVal = row.querySelector('.param-default').value.trim();
|
||||
if (defaultVal !== '') {
|
||||
if (type === 'integer') def.default = parseInt(defaultVal, 10);
|
||||
else if (type === 'float') def.default = parseFloat(defaultVal);
|
||||
else if (type === 'boolean') def.default = defaultVal.toLowerCase() === 'true';
|
||||
else def.default = defaultVal;
|
||||
}
|
||||
|
||||
if (type === 'integer' || type === 'float') {
|
||||
const minVal = row.querySelector('.param-min').value.trim();
|
||||
const maxVal = row.querySelector('.param-max').value.trim();
|
||||
if (minVal !== '') def.min = parseFloat(minVal);
|
||||
if (maxVal !== '') def.max = parseFloat(maxVal);
|
||||
}
|
||||
|
||||
if (type === 'select') {
|
||||
const optStr = row.querySelector('.param-options').value.trim();
|
||||
if (optStr) def.options = optStr.split(',').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
params[name] = def;
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
export async function executeCallbackDebug(callbackName) {
|
||||
const dialog = document.getElementById('executionDialog');
|
||||
const title = document.getElementById('executionDialogTitle');
|
||||
|
||||
Reference in New Issue
Block a user