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:
@@ -40,6 +40,7 @@ import {
|
||||
showAddScriptDialog, showEditScriptDialog, closeScriptDialog, saveScript,
|
||||
deleteScriptConfirm, executeScriptDebug, executeCallbackDebug,
|
||||
closeExecutionDialog, scriptFormDirty, setScriptFormDirty,
|
||||
addParameterRow, closeScriptParamsDialog, submitScriptWithParams,
|
||||
} from './scripts.js';
|
||||
|
||||
import {
|
||||
@@ -106,6 +107,7 @@ Object.assign(window, {
|
||||
showAddScriptDialog, showEditScriptDialog, closeScriptDialog, saveScript,
|
||||
deleteScriptConfirm, executeScriptDebug, executeCallbackDebug,
|
||||
closeExecutionDialog,
|
||||
addParameterRow, closeScriptParamsDialog, submitScriptWithParams,
|
||||
// Callbacks
|
||||
showAddCallbackDialog, showEditCallbackDialog, closeCallbackDialog,
|
||||
saveCallback, deleteCallbackConfirm,
|
||||
|
||||
@@ -3,10 +3,34 @@
|
||||
// ============================================================
|
||||
|
||||
import { t, showToast, escapeHtml, closeDialog, showConfirm, getAuthHeaders, hasCredentials } from './core.js';
|
||||
import { IconSelect } from './icon-select.js';
|
||||
import { callbackEventIcons } from './icons.js';
|
||||
|
||||
export let callbackFormDirty = false;
|
||||
export function setCallbackFormDirty(value) { callbackFormDirty = value; }
|
||||
|
||||
let _callbackEventIconSelect = null;
|
||||
|
||||
function _ensureCallbackEventIconSelect() {
|
||||
if (_callbackEventIconSelect) return;
|
||||
const select = document.getElementById('callbackName');
|
||||
if (!select) return;
|
||||
|
||||
const items = Object.entries(callbackEventIcons).map(([value, icon]) => ({
|
||||
value,
|
||||
icon,
|
||||
label: value,
|
||||
}));
|
||||
|
||||
_callbackEventIconSelect = new IconSelect({
|
||||
target: select,
|
||||
items,
|
||||
columns: 3,
|
||||
placeholder: t('callbacks.placeholder.event'),
|
||||
onChange: () => { callbackFormDirty = true; },
|
||||
});
|
||||
}
|
||||
|
||||
let _loadCallbacksPromise = null;
|
||||
export async function loadCallbacksTable() {
|
||||
if (_loadCallbacksPromise) return _loadCallbacksPromise;
|
||||
@@ -71,6 +95,9 @@ export function showAddCallbackDialog() {
|
||||
document.getElementById('callbackName').disabled = false;
|
||||
title.textContent = t('callbacks.dialog.add');
|
||||
|
||||
_ensureCallbackEventIconSelect();
|
||||
if (_callbackEventIconSelect) _callbackEventIconSelect.setValue('', false);
|
||||
|
||||
callbackFormDirty = false;
|
||||
|
||||
document.body.classList.add('dialog-open');
|
||||
@@ -101,6 +128,9 @@ export async function showEditCallbackDialog(callbackName) {
|
||||
document.getElementById('callbackIsEdit').value = 'true';
|
||||
document.getElementById('callbackName').value = callbackName;
|
||||
document.getElementById('callbackName').disabled = true;
|
||||
|
||||
_ensureCallbackEventIconSelect();
|
||||
if (_callbackEventIconSelect) _callbackEventIconSelect.setValue(callbackName, false);
|
||||
document.getElementById('callbackCommand').value = callback.command;
|
||||
document.getElementById('callbackTimeout').value = callback.timeout;
|
||||
document.getElementById('callbackWorkingDir').value = callback.working_dir || '';
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// ============================================================
|
||||
// IconSelect: visual icon-grid selector (replaces <select>)
|
||||
// Ported from wled-screen-controller (TypeScript → vanilla JS)
|
||||
//
|
||||
// Trigger replaces the <select> inline. Popup is absolutely
|
||||
// positioned inside a wrapper that sits next to the trigger.
|
||||
// Works inside <dialog showModal()> — dialog must have
|
||||
// overflow: visible.
|
||||
// ============================================================
|
||||
|
||||
const POPUP_CLASS = 'icon-select-popup';
|
||||
|
||||
let _globalListenerAdded = false;
|
||||
|
||||
export function closeAllIconSelects() {
|
||||
document.querySelectorAll(`.${POPUP_CLASS}.open`).forEach(p => {
|
||||
p.classList.remove('open');
|
||||
});
|
||||
}
|
||||
|
||||
function _ensureGlobalListener() {
|
||||
if (_globalListenerAdded) return;
|
||||
_globalListenerAdded = true;
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest(`.${POPUP_CLASS}`) && !e.target.closest('.icon-select-trigger')) {
|
||||
closeAllIconSelects();
|
||||
}
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeAllIconSelects();
|
||||
});
|
||||
}
|
||||
|
||||
export class IconSelect {
|
||||
constructor({ target, items, onChange, columns = 2, placeholder = '', horizontal = false }) {
|
||||
_ensureGlobalListener();
|
||||
|
||||
this._select = target;
|
||||
this._items = items;
|
||||
this._onChange = onChange;
|
||||
this._columns = columns;
|
||||
this._placeholder = placeholder;
|
||||
this._horizontal = horizontal;
|
||||
|
||||
// Hide native select
|
||||
this._select.style.display = 'none';
|
||||
|
||||
// Trigger button (replaces select visually)
|
||||
this._trigger = document.createElement('button');
|
||||
this._trigger.type = 'button';
|
||||
this._trigger.className = 'icon-select-trigger';
|
||||
this._trigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._toggle();
|
||||
});
|
||||
this._select.parentNode.insertBefore(this._trigger, this._select.nextSibling);
|
||||
|
||||
// Popup — absolutely positioned, appended to dialog (overflow:visible)
|
||||
// or body, escaping any scrollable ancestors
|
||||
this._popup = document.createElement('div');
|
||||
this._popup.className = POPUP_CLASS;
|
||||
this._popup.addEventListener('click', (e) => e.stopPropagation());
|
||||
this._popup.innerHTML = this._buildGrid();
|
||||
|
||||
const portal = this._select.closest('dialog') || document.body;
|
||||
portal.appendChild(this._popup);
|
||||
|
||||
this._bindCells();
|
||||
this._syncTrigger();
|
||||
}
|
||||
|
||||
_buildGrid() {
|
||||
const cells = this._items.map(item =>
|
||||
`<div class="icon-select-cell" data-value="${item.value}">
|
||||
<span class="icon-select-cell-icon">${item.icon}</span>
|
||||
<span class="icon-select-cell-label">${item.label}</span>
|
||||
${item.desc ? `<span class="icon-select-cell-desc">${item.desc}</span>` : ''}
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
const cls = 'icon-select-grid' + (this._horizontal ? ' icon-select-grid--horizontal' : '');
|
||||
return `<div class="${cls}" style="grid-template-columns:repeat(${this._columns},1fr)">${cells}</div>`;
|
||||
}
|
||||
|
||||
_bindCells() {
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
cell.addEventListener('click', () => {
|
||||
this.setValue(cell.dataset.value, true);
|
||||
this._popup.classList.remove('open');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_syncTrigger() {
|
||||
const val = this._select.value;
|
||||
const item = this._items.find(i => i.value === val);
|
||||
if (item) {
|
||||
this._trigger.innerHTML =
|
||||
`<span class="icon-select-trigger-icon">${item.icon}</span>` +
|
||||
`<span class="icon-select-trigger-label">${item.label}</span>` +
|
||||
`<span class="icon-select-trigger-arrow">▾</span>`;
|
||||
} else if (this._placeholder) {
|
||||
this._trigger.innerHTML =
|
||||
`<span class="icon-select-trigger-label">${this._placeholder}</span>` +
|
||||
`<span class="icon-select-trigger-arrow">▾</span>`;
|
||||
}
|
||||
this._popup.querySelectorAll('.icon-select-cell').forEach(cell => {
|
||||
cell.classList.toggle('active', cell.dataset.value === val);
|
||||
});
|
||||
}
|
||||
|
||||
_positionPopup() {
|
||||
// Get trigger position relative to the popup's offset parent
|
||||
// (the dialog or body). Use getBoundingClientRect for both and
|
||||
// compute the offset.
|
||||
const triggerRect = this._trigger.getBoundingClientRect();
|
||||
const parentRect = this._popup.offsetParent
|
||||
? this._popup.offsetParent.getBoundingClientRect()
|
||||
: { left: 0, top: 0 };
|
||||
|
||||
const relTop = triggerRect.bottom - parentRect.top;
|
||||
const relLeft = triggerRect.left - parentRect.left;
|
||||
const popupW = Math.max(triggerRect.width, 200);
|
||||
|
||||
this._popup.style.left = relLeft + 'px';
|
||||
this._popup.style.top = (relTop + 4) + 'px';
|
||||
this._popup.style.width = popupW + 'px';
|
||||
}
|
||||
|
||||
_toggle() {
|
||||
const wasOpen = this._popup.classList.contains('open');
|
||||
closeAllIconSelects();
|
||||
if (!wasOpen) {
|
||||
this._positionPopup();
|
||||
this._popup.classList.add('open');
|
||||
}
|
||||
}
|
||||
|
||||
setValue(value, fireChange = false) {
|
||||
this._select.value = value;
|
||||
this._syncTrigger();
|
||||
if (fireChange) {
|
||||
this._select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
if (this._onChange) this._onChange(value);
|
||||
}
|
||||
}
|
||||
|
||||
updateItems(items) {
|
||||
this._items = items;
|
||||
this._popup.innerHTML = this._buildGrid();
|
||||
this._bindCells();
|
||||
this._syncTrigger();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._trigger.remove();
|
||||
this._popup.remove();
|
||||
this._select.style.display = '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// ============================================================
|
||||
// SVG icon library for icon-select grids
|
||||
// Simple inline SVGs (24x24 viewBox, fill="currentColor")
|
||||
// ============================================================
|
||||
|
||||
const _svg = (path) =>
|
||||
`<svg viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">${path}</svg>`;
|
||||
|
||||
// Parameter types
|
||||
export const paramTypeIcons = {
|
||||
string: _svg('<path d="M3 7V5h18v2H3zm0 12v-2h12v2H3zm0-6v-2h18v2H3z"/>'),
|
||||
integer: _svg('<path d="M4 17V7h2v4h3V7h2v10h-2v-4H6v4H4zm10-1h2v1h2v-4h-2v1h-2V9h6v8h-6v-1z"/>'),
|
||||
float: _svg('<path d="M5 17V7h2v4h3V7h2v10H9v-4H7v4H5zm9.5 0v-2a1 1 0 1 1 0-2h1v-2h-1a3 3 0 0 0 0 6h1v2h-1zm3-6v2h1a1 1 0 1 1 0 2h-1v2h1a3 3 0 0 0 0-6h-1z"/>'),
|
||||
boolean: _svg('<path d="M17 7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h10c2.76 0 5-2.24 5-5s-2.24-5-5-5zm0 8c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3z"/>'),
|
||||
select: _svg('<path d="M3 5h18v2H3V5zm4 6h10v2H7v-2zm-4 6h18v2H3v-2z"/><path d="M7 7l5 5 5-5"/>'),
|
||||
};
|
||||
|
||||
// Callback events
|
||||
export const callbackEventIcons = {
|
||||
on_play: _svg('<path d="M8 5v14l11-7z"/>'),
|
||||
on_pause: _svg('<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>'),
|
||||
on_stop: _svg('<path d="M6 6h12v12H6z"/>'),
|
||||
on_next: _svg('<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>'),
|
||||
on_previous: _svg('<path d="M6 6h2v12H6V6zm3.5 6l8.5 6V6l-8.5 6z"/>'),
|
||||
on_volume: _svg('<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8.5v7a4.5 4.5 0 0 0 2.5-3.5zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"/>'),
|
||||
on_mute: _svg('<path d="M16.5 12A4.5 4.5 0 0 0 14 8.5v2.09l2.41 2.41c.06-.31.09-.65.09-1zM19 12c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.796 8.796 0 0 0 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06a8.99 8.99 0 0 0 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>'),
|
||||
on_seek: _svg('<path d="M12 5V1L7 6l5 5V7c3.31 0 6 2.69 6 6s-2.69 6-6 6-6-2.69-6-6H4c0 4.42 3.58 8 8 8s8-3.58 8-8-3.58-8-8-8z"/>'),
|
||||
on_turn_on: _svg('<path d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0 1 19 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.27 1.08-4.28 2.76-5.56L6.34 5.02A8.96 8.96 0 0 0 3 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.96 8.96 0 0 0-3.17-6.83z"/>'),
|
||||
on_turn_off: _svg('<path d="M13 3h-2v10h2V3zm4.83 2.17l-1.42 1.42A6.92 6.92 0 0 1 19 12c0 3.87-3.13 7-7 7s-7-3.13-7-7c0-2.27 1.08-4.28 2.76-5.56L6.34 5.02A8.96 8.96 0 0 0 3 12c0 4.97 4.03 9 9 9s9-4.03 9-9a8.96 8.96 0 0 0-3.17-6.83z"/>'),
|
||||
on_toggle: _svg('<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>'),
|
||||
};
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from './core.js';
|
||||
import { updateBackgroundColors } from './background.js';
|
||||
import { loadDisplayMonitors } from './links.js';
|
||||
import { IconSelect } from './icon-select.js';
|
||||
|
||||
// Tab management
|
||||
export let activeTab = 'player';
|
||||
@@ -422,6 +423,8 @@ function renderVisualizerFrame() {
|
||||
}
|
||||
|
||||
// Audio device selection
|
||||
let _audioDeviceIconSelect = null;
|
||||
|
||||
export async function loadAudioDevices() {
|
||||
const section = document.getElementById('audioDeviceSection');
|
||||
const select = document.getElementById('audioDeviceSelect');
|
||||
@@ -466,6 +469,22 @@ export async function loadAudioDevices() {
|
||||
}
|
||||
}
|
||||
|
||||
// Enhance with icon grid
|
||||
const audioSvg = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0 0 14 8.5v7a4.5 4.5 0 0 0 2.5-3.5z"/></svg>';
|
||||
const items = [
|
||||
{ value: '', icon: audioSvg, label: t('settings.audio.auto') },
|
||||
...devices.map(dev => ({ value: dev.name, icon: audioSvg, label: dev.name })),
|
||||
];
|
||||
if (_audioDeviceIconSelect) _audioDeviceIconSelect.destroy();
|
||||
_audioDeviceIconSelect = new IconSelect({
|
||||
target: select,
|
||||
items,
|
||||
columns: 1,
|
||||
horizontal: true,
|
||||
onChange: () => onAudioDeviceChanged(),
|
||||
});
|
||||
_audioDeviceIconSelect.setValue(select.value, false);
|
||||
|
||||
updateAudioDeviceStatus(status);
|
||||
} catch (e) {
|
||||
section.style.display = 'none';
|
||||
|
||||
@@ -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