mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* refactor(web): drop validators nothing calls escape_html, validate_image_url, validate_font_awesome_class, validate_mime_type, validate_numeric_range, validate_string_length and sanitize_plugin_config had no callers outside their own tests. Only validate_file_upload (fonts upload) is imported by the web interface. dedup_unique_arrays is kept: its one caller in save_plugin_config was removed by the unrelated sync PR (#330), which looks accidental. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(api): remove the music-auth and of-the-day JSON routes POST /plugins/authenticate/spotify and /plugins/authenticate/ytm had no caller but their tests: the music plugin authenticates through its web_ui_actions (authenticate_spotify.py / authenticate_ytm.py) via /plugins/action. POST /plugins/of-the-day/json/upload and /json/delete looked the plugin up by the id ledmatrix-of-the-day (its manifest id is of-the-day), were reachable only from a file_type "json" upload field that no schema declares, and put the plugin directory on sys.path per request to import scripts.update_config. of-the-day manages its files through plugin-file-manager and its own web_ui_actions. The of-the-day branch of GET /plugins/config stays: it matches the real manifest id and still merges the on-disk category files into the form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(api): read managers only from the blueprints api_v3/__init__.py and pages_v3.py declared module globals (plugin_store_manager, saved_repositories_manager, schema_manager, operation_queue, plugin_state_manager, operation_history, sync_manager, config_manager, plugin_manager) that nothing assigns: app.py sets the managers as attributes on the Blueprint objects, and every route reads them there. The one reader, backup restore's fallback to the module plugin_store_manager, could only ever fall back to None. _ensure_cache_manager() built a second CacheManager in the web process instead of using the one app.py puts on api_v3. The display routes now read api_v3.cache_manager, creating it on the blueprint only when nothing set it (the same None handling as the /cache routes). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(web): drop run.sh and the unused log_config_change web_interface/run.sh was referenced only by web_interface/README.md; the service starts the UI through scripts/utils/start_web_conditionally.py and the README already documents `python3 web_interface/start.py`. log_config_change() in web_interface/logging_config.py was never called. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): delete unreferenced store_manager.js, diff_viewer.js, htmx-sse.js - js/plugins/store_manager.js (window.PluginStoreManager) and js/config/diff_viewer.js (window.ConfigDiffViewer) were loaded on every page but nothing reads either global. - htmx-sse.js (plus its CDN fallback) was loaded after HTMX, but no template or plugin page uses sse-connect / hx-ext="sse": the live streams run through LEDStreams in app-shell.js. js/plugins/state_manager.js stays: install_manager.js's updateAll() reads and refreshes window.PluginStateManager. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): remove app.js helpers nothing calls - hexToRgb, rgbToHex, validateForm, uploadFont and switchTab (whose 'switch-tab' event had no listener) have no caller in the templates, static JS or the plugin monorepo. - installPlugin: plugins_manager.js (loaded last) assigns window.installPlugin, and its own store cards are the only callers. - The showNotification fallback could never install: app-shell.js is deferred ahead of app.js and defines the same fallback at top level. - performanceMonitor only logged with ?debug=perf and read an unset this.measures; the marks it took on every load had no reader. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): drop app-shell.js refreshPlugin A top-level function in app-shell.js, so a window global, but nothing calls it (no inline handler, no window lookup, no string-built name). The other plugin actions in that block stay. updatePlugin is the live window.updatePlugin: plugins_manager.js only installs its own copy when none exists. uninstallPlugin/pollUninstallOperation, updateAllPlugins, executePluginAction and toggleNestedSection are replaced by later deferred scripts, but a click that lands while those scripts are still downloading reaches the app-shell copies, so removing them is not a pure no-op. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): remove definitions plugins_manager.js always overrides All of these are replaced before anything can call them, checked against the load order in base.html and the live window.* values: - openOnDemandModal/requestOnDemandStop stubs: the IIFE later in the same script assigns the real functions synchronously. - updatePlugin and uninstallPlugin stubs (`window.X || stub`): app-shell.js already defined both, so the fallback never installed. Same for the later updatePlugin override, gated on the live function containing '[UPDATE]', which app-shell.js's never does. - The first addArrayObjectItem/removeArrayObjectItem: reassigned by the top-level copies after the IIFE. - The first `function formatDate` in the IIFE: a later declaration of the same name in the same scope wins. - deleteUploadedImage, getCurrentImages, showUploadProgress, formatFileSize and getScheduleSummary: character-for-character copies of js/widgets/file-upload.js, which stays the owner. - `typeof X === 'undefined'` fallbacks and `typeof X !== 'undefined'` re-exports after the IIFE: always false, or a self-assignment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(web): render the shell directly and delete index.html index.html extended base.html with {% block content %}, but base.html defines no blocks, so none of index.html ever rendered: rendering both with jinja2 gives byte-identical output. index() still loaded the config, read config.json and config_secrets.json raw and json.dumps'd them on every page load for variables base.html never reads, and flashed errors that base.html never shows. It now renders base.html with no context. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): stop htmx-config.js replacing console.error and console.warn It swapped both globals for filters that dropped any error mentioning insertBefore / "Cannot read properties of null" when "htmx" appeared in the message or stack, and a list of Permissions-Policy warnings. That hid real errors from every script on the page, and made every logged error and warning report htmx-config.js as its source. The beforeSwap target validation above it, which prevents the insertBefore errors in the first place, stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore(web): quiet the widget load announcements and debug logs About 30 lines hit the console on every page load: one "... widget registered" per widget file, one "[WidgetRegistry] Registered widget: X" per registration, plus the registry, base widget and plugin loader announcing themselves. The load-time announcements are removed; the per-call ones (registry register, plugin widget loads, "Render called") now go through the page's debugLog switch (localStorage.pluginDebug), guarded because the widgets also load in node tests without it. fonts.html and wifi.html debug logging goes through debugLog as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(api): drop the removed music-auth and of-the-day JSON routes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
922 lines
43 KiB
JavaScript
922 lines
43 KiB
JavaScript
/**
|
|
* Array Table Widget
|
|
*
|
|
* Generic table-based array-of-objects editor.
|
|
* Handles adding, removing, and editing array items with object properties.
|
|
* Reads column definitions from the schema's items.properties.
|
|
*
|
|
* Supported x-widget hints on item properties:
|
|
* date-picker → <input type="date">
|
|
* time-picker → <input type="time">
|
|
* file-upload-single → compact path input + upload button
|
|
* (enum values always render as <select>)
|
|
*
|
|
* Non-displayed properties (objects like layout/style) are stored in a hidden
|
|
* cell and editable via the ⚙ row editor modal.
|
|
*
|
|
* @module ArrayTableWidget
|
|
*/
|
|
|
|
(function() {
|
|
'use strict';
|
|
|
|
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
|
console.error('[ArrayTableWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
|
|
return;
|
|
}
|
|
|
|
// ─── Widget registration ────────────────────────────────────────────────
|
|
|
|
window.LEDMatrixWidgets.register('array-table', {
|
|
name: 'Array Table Widget',
|
|
version: '2.0.0',
|
|
|
|
render: function(container, config, value, options) {
|
|
if (window.debugLog) window.debugLog('[ArrayTableWidget] Render called (server-side rendered)');
|
|
},
|
|
|
|
getValue: function(fieldId) {
|
|
const tbody = document.getElementById(`${fieldId}_tbody`);
|
|
if (!tbody) return [];
|
|
|
|
const rows = tbody.querySelectorAll('.array-table-row');
|
|
const items = [];
|
|
|
|
rows.forEach((row) => {
|
|
const item = {};
|
|
|
|
// Collect all named form controls (input + select), skip type=hidden except
|
|
// for boolean hidden sentinels (those end in the field name only, not .enabled).
|
|
row.querySelectorAll('input, select').forEach(el => {
|
|
const name = el.getAttribute('name');
|
|
if (!name) return;
|
|
// Skip hidden inputs that are boolean sentinels (they duplicate checkboxes)
|
|
if (el.type === 'hidden' && !el.dataset.nestedProp) return;
|
|
|
|
// Nested advanced props stored in hidden cell
|
|
if (el.dataset.nestedProp) {
|
|
const propPath = el.dataset.nestedProp;
|
|
setNestedValue(item, propPath, coerceValue(el.value, el.dataset.propType || 'string'));
|
|
return;
|
|
}
|
|
|
|
// Standard display-column props: name matches fullKey.index.propName[.subKey...]
|
|
const match = name.match(/\.\d+\.(.+)$/);
|
|
if (!match) return;
|
|
const propPath = match[1];
|
|
|
|
if (el.tagName === 'SELECT') {
|
|
setNestedValue(item, propPath, el.value);
|
|
} else if (el.type === 'checkbox') {
|
|
setNestedValue(item, propPath, el.checked);
|
|
} else if (el.type === 'number') {
|
|
setNestedValue(item, propPath, el.value !== '' ? parseFloat(el.value) : null);
|
|
} else {
|
|
setNestedValue(item, propPath, el.value);
|
|
}
|
|
});
|
|
|
|
if (Object.keys(item).length > 0) items.push(item);
|
|
});
|
|
|
|
return items;
|
|
},
|
|
|
|
setValue: function(fieldId, items, options) {
|
|
if (!Array.isArray(items)) {
|
|
console.error('[ArrayTableWidget] setValue expects an array');
|
|
return;
|
|
}
|
|
if (!options || !options.fullKey || !options.pluginId) {
|
|
throw new Error('ArrayTableWidget.setValue requires options.fullKey and options.pluginId');
|
|
}
|
|
|
|
const tbody = document.getElementById(`${fieldId}_tbody`);
|
|
if (!tbody) return;
|
|
|
|
tbody.innerHTML = '';
|
|
items.forEach((item, index) => {
|
|
const row = createArrayTableRow(
|
|
fieldId, options.fullKey, index, options.pluginId,
|
|
item, options.itemProperties || {}, options.displayColumns || [],
|
|
options.fullItemProperties || options.itemProperties || {}
|
|
);
|
|
tbody.appendChild(row);
|
|
});
|
|
updateAddButtonState(fieldId);
|
|
},
|
|
|
|
handlers: {}
|
|
});
|
|
|
|
// ─── Helpers ────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* "x-display": "hidden" — the property stays in the schema but gets no
|
|
* control. An object whose every child is hidden counts as hidden too.
|
|
* Mirrors prop_is_hidden in plugin_config.html and _is_hidden_prop on the
|
|
* server.
|
|
*/
|
|
function isHiddenProp(schema) {
|
|
if (!schema || typeof schema !== 'object') return false;
|
|
if (schema['x-display'] === 'hidden') return true;
|
|
const children = schema.properties;
|
|
if (children && typeof children === 'object') {
|
|
const values = Object.values(children);
|
|
return values.length > 0 && values.every(isHiddenProp);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* A hidden input carrying a hidden property's stored value, JSON-encoded so
|
|
* the save restores its exact type. A posted row replaces the stored item,
|
|
* so a value that isn't posted is lost.
|
|
*/
|
|
function hiddenValueInput(name, dataKey, value) {
|
|
const input = document.createElement('input');
|
|
input.type = 'hidden';
|
|
input.name = name;
|
|
input.value = JSON.stringify(value);
|
|
input.dataset.hiddenProp = dataKey;
|
|
return input;
|
|
}
|
|
|
|
function safeSetHTML(target, html) {
|
|
target.textContent = '';
|
|
// createContextualFragment parses html relative to the document context
|
|
// without executing scripts — a widely recognised safe insertion method.
|
|
const frag = document.createRange().createContextualFragment(html);
|
|
target.appendChild(frag);
|
|
}
|
|
|
|
// Keys that must never be assigned to prevent prototype pollution.
|
|
const _FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
|
|
|
|
function setNestedValue(obj, path, value) {
|
|
const parts = path.split('.');
|
|
let cur = obj;
|
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
const key = parts[i];
|
|
if (_FORBIDDEN_KEYS.has(key)) return;
|
|
// Use hasOwnProperty to avoid reading inherited prototype properties,
|
|
// and defineProperty to write without triggering prototype setters.
|
|
if (!Object.hasOwn(cur, key) ||
|
|
typeof Object.getOwnPropertyDescriptor(cur, key).value !== 'object') {
|
|
Object.defineProperty(cur, key, {
|
|
value: Object.create(null), writable: true,
|
|
enumerable: true, configurable: true
|
|
});
|
|
}
|
|
cur = Object.getOwnPropertyDescriptor(cur, key).value;
|
|
}
|
|
const lastKey = parts[parts.length - 1];
|
|
if (!_FORBIDDEN_KEYS.has(lastKey)) {
|
|
Object.defineProperty(cur, lastKey, {
|
|
value: value, writable: true, enumerable: true, configurable: true
|
|
});
|
|
}
|
|
}
|
|
|
|
function coerceValue(strVal, typeHint) {
|
|
if (strVal === '' || strVal === null || strVal === undefined) return null;
|
|
if (typeHint === 'integer') return parseInt(strVal, 10);
|
|
if (typeHint === 'number') return parseFloat(strVal);
|
|
if (typeHint === 'boolean') return strVal === 'true' || strVal === '1';
|
|
// nullable integer/number: "integer|null"
|
|
if (typeHint && typeHint.includes('integer')) return strVal !== '' ? parseInt(strVal, 10) : null;
|
|
if (typeHint && typeHint.includes('number')) return strVal !== '' ? parseFloat(strVal) : null;
|
|
return strVal;
|
|
}
|
|
|
|
// ─── Cell rendering ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Visible text for one enum option.
|
|
*
|
|
* Mirrors the server-rendered table in plugin_config.html: a schema may
|
|
* supply x-options.labels, and anything unlabelled falls back to the raw
|
|
* value. Rows added here must match rows rendered by the template, or the
|
|
* same column would read differently before and after a page reload.
|
|
*
|
|
* @param {Object} colDef column (or property) schema
|
|
* @param {*} opt the enum value
|
|
* @returns {string} label to display
|
|
*/
|
|
function enumOptionLabel(colDef, opt) {
|
|
const options = (colDef && (colDef['x-options'] || colDef['x_options'])) || {};
|
|
const labels = options.labels || {};
|
|
return Object.prototype.hasOwnProperty.call(labels, opt) ? labels[opt] : opt;
|
|
}
|
|
|
|
/**
|
|
* Create one <td> for a display column.
|
|
*/
|
|
function createCell(fullKey, index, colName, colDef, colValue, pluginId) {
|
|
const colType = Array.isArray(colDef.type) ? colDef.type.find(t => t !== 'null') || 'string' : (colDef.type || 'string');
|
|
const xWidget = colDef['x-widget'] || colDef['x_widget'];
|
|
const enumVals = colDef.enum;
|
|
const inputName = `${fullKey}.${index}.${colName}`;
|
|
|
|
const cell = document.createElement('td');
|
|
cell.className = 'px-3 py-3 whitespace-nowrap';
|
|
cell.style.verticalAlign = 'middle';
|
|
|
|
if (colType === 'boolean') {
|
|
// Boolean: hidden sentinel + visible checkbox, same `name` (so
|
|
// unchecked boxes still submit "false"). Keep the hidden's value
|
|
// synced to the checkbox at all times — some form-collection
|
|
// paths prefer whichever of the two same-named inputs comes
|
|
// first/last in the DOM, and a stale hidden value previously
|
|
// caused this field to silently revert to false on every save.
|
|
const hidden = document.createElement('input');
|
|
hidden.type = 'hidden';
|
|
hidden.name = inputName;
|
|
hidden.value = String(Boolean(colValue));
|
|
cell.appendChild(hidden);
|
|
|
|
const cb = document.createElement('input');
|
|
cb.type = 'checkbox';
|
|
cb.name = inputName;
|
|
cb.checked = Boolean(colValue);
|
|
cb.value = 'true';
|
|
cb.className = 'h-4 w-4 text-blue-600';
|
|
cb.addEventListener('change', () => {
|
|
hidden.value = String(cb.checked);
|
|
});
|
|
cell.appendChild(cb);
|
|
|
|
} else if (colType === 'integer' || colType === 'number') {
|
|
const inp = document.createElement('input');
|
|
inp.type = 'number';
|
|
inp.name = inputName;
|
|
inp.value = colValue !== null && colValue !== undefined ? colValue : '';
|
|
if (colDef.minimum !== undefined) inp.min = colDef.minimum;
|
|
if (colDef.maximum !== undefined) inp.max = colDef.maximum;
|
|
inp.step = colType === 'integer' ? '1' : 'any';
|
|
inp.className = 'block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center';
|
|
if (colDef.description) inp.title = colDef.description;
|
|
cell.appendChild(inp);
|
|
|
|
} else if (Array.isArray(enumVals) && enumVals.length > 0) {
|
|
// Enum: render <select>
|
|
cell.style.minWidth = '90px';
|
|
const sel = document.createElement('select');
|
|
sel.name = inputName;
|
|
sel.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white';
|
|
enumVals.forEach(opt => {
|
|
if (opt === null) return;
|
|
const o = document.createElement('option');
|
|
o.value = opt;
|
|
o.textContent = enumOptionLabel(colDef, opt);
|
|
if (String(colValue) === String(opt)) o.selected = true;
|
|
sel.appendChild(o);
|
|
});
|
|
// If current value didn't match any option, set to first
|
|
if (!sel.value && enumVals.length > 0) sel.value = enumVals[0];
|
|
cell.appendChild(sel);
|
|
|
|
} else if (xWidget === 'date-picker') {
|
|
cell.style.minWidth = '140px';
|
|
const inp = document.createElement('input');
|
|
inp.type = 'date';
|
|
inp.name = inputName;
|
|
inp.value = colValue || '';
|
|
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
|
inp.style.minWidth = '128px';
|
|
if (colDef.description) inp.title = colDef.description;
|
|
cell.appendChild(inp);
|
|
|
|
} else if (xWidget === 'time-picker') {
|
|
cell.style.minWidth = '115px';
|
|
const inp = document.createElement('input');
|
|
inp.type = 'time';
|
|
inp.name = inputName;
|
|
inp.value = colValue || '00:00';
|
|
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
|
inp.style.minWidth = '100px';
|
|
cell.appendChild(inp);
|
|
|
|
} else if (xWidget === 'file-upload-single') {
|
|
// Compact: text input (stores path) + upload button
|
|
cell.style.minWidth = '200px';
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'flex items-center gap-1';
|
|
|
|
const pathInput = document.createElement('input');
|
|
pathInput.type = 'text';
|
|
pathInput.name = inputName;
|
|
pathInput.id = `${fullKey}_${index}_${colName}`.replace(/\./g,'_');
|
|
pathInput.value = colValue || '';
|
|
pathInput.className = 'block px-1 py-1 border border-gray-300 rounded text-xs flex-1';
|
|
pathInput.style.minWidth = '100px';
|
|
pathInput.placeholder = 'path…';
|
|
|
|
const preview = document.createElement('img');
|
|
preview.className = 'w-6 h-6 object-cover rounded flex-shrink-0';
|
|
preview.style.display = colValue ? 'inline' : 'none';
|
|
if (colValue) { preview.src = '/' + colValue; preview.onerror = () => { preview.style.display = 'none'; }; }
|
|
|
|
const labelEl = document.createElement('label');
|
|
labelEl.className = 'cursor-pointer flex-shrink-0 inline-flex items-center px-1 py-1 bg-blue-50 border border-blue-200 rounded text-xs text-blue-600 hover:bg-blue-100';
|
|
labelEl.title = 'Upload image';
|
|
labelEl.innerHTML = '<i class="fas fa-upload"></i>';
|
|
|
|
const fileInput = document.createElement('input');
|
|
fileInput.type = 'file';
|
|
fileInput.accept = 'image/png,image/jpeg,image/bmp,image/gif';
|
|
fileInput.style.display = 'none';
|
|
fileInput.dataset.pluginId = pluginId;
|
|
fileInput.dataset.targetInput = pathInput.id;
|
|
fileInput.dataset.previewImg = preview.id || '';
|
|
fileInput.onchange = function(e) {
|
|
window.handleArrayTableImageUpload(e, pathInput, preview, pluginId);
|
|
};
|
|
labelEl.appendChild(fileInput);
|
|
|
|
wrap.appendChild(preview);
|
|
wrap.appendChild(pathInput);
|
|
wrap.appendChild(labelEl);
|
|
cell.appendChild(wrap);
|
|
|
|
} else {
|
|
// Default: text input
|
|
const inp = document.createElement('input');
|
|
inp.type = 'text';
|
|
inp.name = inputName;
|
|
inp.value = colValue !== null && colValue !== undefined ? colValue : '';
|
|
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
|
if (colDef.description) inp.placeholder = colDef.description;
|
|
if (colDef.pattern) inp.pattern = colDef.pattern;
|
|
if (colDef.minLength) inp.minLength = colDef.minLength;
|
|
if (colDef.maxLength) inp.maxLength = colDef.maxLength;
|
|
cell.appendChild(inp);
|
|
}
|
|
|
|
return cell;
|
|
}
|
|
|
|
/**
|
|
* Create a hidden <td> holding flat hidden inputs for non-displayed properties
|
|
* (including nested objects like layout/style).
|
|
*/
|
|
function createAdvancedCell(fullKey, index, nonDisplayedProps, item) {
|
|
const cell = document.createElement('td');
|
|
cell.style.display = 'none';
|
|
cell.className = 'array-table-advanced-data';
|
|
cell.dataset.propSchema = JSON.stringify(nonDisplayedProps);
|
|
|
|
Object.entries(nonDisplayedProps).forEach(([propName, propSchema]) => {
|
|
const propType = Array.isArray(propSchema.type)
|
|
? propSchema.type.find(t => t !== 'null') || 'string'
|
|
: (propSchema.type || 'string');
|
|
|
|
if (propType === 'object' && propSchema.properties) {
|
|
const nestedVal = (item && item[propName]) || {};
|
|
Object.entries(propSchema.properties).forEach(([subName, subSchema]) => {
|
|
if (isHiddenProp(subSchema)) {
|
|
// No data-nested-prop, so the row editor never shows it.
|
|
const stored = Object.entries(nestedVal).find(([name]) => name === subName);
|
|
if (stored && stored[1] !== undefined && stored[1] !== null) {
|
|
cell.appendChild(hiddenValueInput(
|
|
`${fullKey}.${index}.${propName}.${subName}`,
|
|
`${propName}.${subName}`, stored[1]));
|
|
}
|
|
return;
|
|
}
|
|
const subType = Array.isArray(subSchema.type)
|
|
? subSchema.type.find(t => t !== 'null') || 'string'
|
|
: (subSchema.type || 'string');
|
|
const defaultVal = subSchema.default !== undefined ? subSchema.default : null;
|
|
const currentVal = nestedVal[subName] !== undefined ? nestedVal[subName] : defaultVal;
|
|
|
|
const hidden = document.createElement('input');
|
|
hidden.type = 'hidden';
|
|
hidden.name = `${fullKey}.${index}.${propName}.${subName}`;
|
|
hidden.value = currentVal !== null && currentVal !== undefined ? String(currentVal) : '';
|
|
hidden.dataset.nestedProp = `${propName}.${subName}`;
|
|
hidden.dataset.propType = subType;
|
|
hidden.dataset.propSchema = JSON.stringify(subSchema);
|
|
cell.appendChild(hidden);
|
|
});
|
|
} else {
|
|
const defaultVal = propSchema.default !== undefined ? propSchema.default : null;
|
|
const currentVal = item && item[propName] !== undefined ? item[propName] : defaultVal;
|
|
|
|
const hidden = document.createElement('input');
|
|
hidden.type = 'hidden';
|
|
hidden.name = `${fullKey}.${index}.${propName}`;
|
|
hidden.value = currentVal !== null && currentVal !== undefined ? String(currentVal) : '';
|
|
hidden.dataset.nestedProp = propName;
|
|
hidden.dataset.propType = propType;
|
|
hidden.dataset.propSchema = JSON.stringify(propSchema);
|
|
cell.appendChild(hidden);
|
|
}
|
|
});
|
|
|
|
return cell;
|
|
}
|
|
|
|
// ─── Row creation ────────────────────────────────────────────────────────
|
|
|
|
function createArrayTableRow(fieldId, fullKey, index, pluginId, item, itemProperties, displayColumns, fullItemProperties) {
|
|
item = item || {};
|
|
fullItemProperties = fullItemProperties || itemProperties;
|
|
|
|
const row = document.createElement('tr');
|
|
row.className = 'array-table-row';
|
|
row.setAttribute('data-index', index);
|
|
|
|
// "x-display": "hidden" item properties: never a column (even if
|
|
// x-columns names one), never in the advanced cell or row editor.
|
|
const hiddenKeys = new Set(
|
|
Object.entries(fullItemProperties)
|
|
.filter(([, propSchema]) => isHiddenProp(propSchema))
|
|
.map(([propName]) => propName)
|
|
);
|
|
|
|
// Visible column cells
|
|
displayColumns = displayColumns.filter(colName => !hiddenKeys.has(colName));
|
|
displayColumns.forEach(colName => {
|
|
const colDef = itemProperties[colName] || {};
|
|
const colType = Array.isArray(colDef.type) ? colDef.type.find(t => t !== 'null') || 'string' : (colDef.type || 'string');
|
|
const colDefault = colDef.default !== undefined ? colDef.default
|
|
: (colType === 'boolean' ? false : colType === 'time-picker' ? '00:00' : '');
|
|
const colValue = item[colName] !== undefined ? item[colName] : colDefault;
|
|
row.appendChild(createCell(fullKey, index, colName, colDef, colValue, pluginId));
|
|
});
|
|
|
|
// Determine non-displayed properties (these go into the advanced cell + edit modal).
|
|
// Hidden ones ("x-display": "hidden") get no column and no editor field.
|
|
const nonDisplayed = {};
|
|
Object.keys(fullItemProperties).forEach(k => {
|
|
if (!displayColumns.includes(k) && k !== 'id' && !hiddenKeys.has(k)) {
|
|
nonDisplayed[k] = fullItemProperties[k];
|
|
}
|
|
});
|
|
const hasAdvanced = Object.keys(nonDisplayed).length > 0;
|
|
|
|
// Actions cell
|
|
const actionsCell = document.createElement('td');
|
|
actionsCell.className = 'px-3 py-3 whitespace-nowrap text-center';
|
|
actionsCell.style.minWidth = '90px';
|
|
actionsCell.style.verticalAlign = 'middle';
|
|
|
|
const removeBtn = document.createElement('button');
|
|
removeBtn.type = 'button';
|
|
removeBtn.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
|
removeBtn.onclick = function() { window.removeArrayTableRow(this); };
|
|
removeBtn.setAttribute('aria-label', 'Remove row');
|
|
removeBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>';
|
|
actionsCell.appendChild(removeBtn);
|
|
|
|
if (hasAdvanced) {
|
|
const editBtn = document.createElement('button');
|
|
editBtn.type = 'button';
|
|
editBtn.className = 'text-blue-500 hover:text-blue-700 px-2 py-1 ml-1';
|
|
editBtn.title = 'Edit advanced properties (layout, style…)';
|
|
editBtn.onclick = function() { window.openArrayTableRowEditor(this); };
|
|
editBtn.setAttribute('aria-label', 'Edit advanced properties');
|
|
editBtn.innerHTML = '<i class="fas fa-sliders-h" aria-hidden="true"></i>';
|
|
actionsCell.appendChild(editBtn);
|
|
}
|
|
|
|
// Hidden properties: carry stored values only. A new row has none, so
|
|
// nothing is invented (countdown fills in its own id).
|
|
Object.entries(item).forEach(([propName, stored]) => {
|
|
if (hiddenKeys.has(propName) && stored !== undefined && stored !== null) {
|
|
actionsCell.appendChild(hiddenValueInput(`${fullKey}.${index}.${propName}`, propName, stored));
|
|
}
|
|
});
|
|
|
|
row.appendChild(actionsCell);
|
|
|
|
// Hidden advanced data cell
|
|
if (hasAdvanced) {
|
|
row.appendChild(createAdvancedCell(fullKey, index, nonDisplayed, item));
|
|
}
|
|
|
|
return row;
|
|
}
|
|
|
|
// ─── Row editor modal ────────────────────────────────────────────────────
|
|
|
|
window.openArrayTableRowEditor = function(button) {
|
|
const row = button.closest('tr');
|
|
const advancedCell = row.querySelector('.array-table-advanced-data');
|
|
if (!advancedCell) return;
|
|
|
|
const schema = JSON.parse(advancedCell.dataset.propSchema || '{}');
|
|
// Close any existing modal (also releases its focus trap)
|
|
window.closeArrayTableRowEditor();
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.id = 'array-row-editor-modal';
|
|
// Use inline styles for position/dimensions — inset-0 may be purged from the CSS bundle
|
|
// since it only appears in JS-generated markup, not in scanned templates.
|
|
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:9999;display:flex;align-items:center;justify-content:center;padding:1rem;background:rgba(0,0,0,0.5);';
|
|
overlay.onclick = function(e) { if (e.target === overlay) window.closeArrayTableRowEditor(); };
|
|
|
|
const dialog = document.createElement('div');
|
|
dialog.className = 'bg-white rounded-lg shadow-xl max-w-lg w-full max-h-screen overflow-y-auto';
|
|
|
|
// Header
|
|
safeSetHTML(dialog, `
|
|
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200">
|
|
<h3 id="array-row-editor-title" class="text-base font-semibold text-gray-900">Advanced Properties</h3>
|
|
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
|
aria-label="Close advanced properties"
|
|
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times" aria-hidden="true"></i></button>
|
|
</div>`);
|
|
|
|
const body = document.createElement('div');
|
|
body.className = 'px-5 py-4 space-y-4';
|
|
|
|
// Render a field for each advanced property
|
|
Object.entries(schema).forEach(([propName, propSchema]) => {
|
|
const propType = Array.isArray(propSchema.type)
|
|
? propSchema.type.find(t => t !== 'null') || 'string'
|
|
: (propSchema.type || 'string');
|
|
const label = propSchema.title || propName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
const desc = propSchema.description || '';
|
|
|
|
if (propType === 'object' && propSchema.properties) {
|
|
// Section for nested object
|
|
const section = document.createElement('div');
|
|
section.className = 'border border-gray-200 rounded-lg p-3';
|
|
const _secH4 = document.createElement('h4');
|
|
_secH4.className = 'text-sm font-medium text-gray-700 mb-3';
|
|
_secH4.textContent = label;
|
|
section.appendChild(_secH4);
|
|
|
|
const grid = document.createElement('div');
|
|
grid.className = 'grid grid-cols-2 gap-3';
|
|
|
|
Object.entries(propSchema.properties).forEach(([subName, subSchema]) => {
|
|
if (isHiddenProp(subSchema)) return;
|
|
const subType =Array.isArray(subSchema.type) ? subSchema.type.find(t => t !== 'null') || 'string' : (subSchema.type || 'string');
|
|
const subLabel = subSchema.title || subName.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
const subDesc = subSchema.description || '';
|
|
const nestedPath = `${propName}.${subName}`;
|
|
|
|
// Read current value from hidden input
|
|
const hiddenInput = advancedCell.querySelector(`[data-nested-prop="${nestedPath}"]`);
|
|
const currentVal = hiddenInput ? hiddenInput.value : (subSchema.default !== undefined ? subSchema.default : '');
|
|
|
|
const fieldDiv = document.createElement('div');
|
|
const _subLbl = document.createElement('label');
|
|
_subLbl.className = 'block text-xs font-medium text-gray-600 mb-1';
|
|
_subLbl.title = subDesc;
|
|
_subLbl.textContent = subLabel;
|
|
fieldDiv.appendChild(_subLbl);
|
|
fieldDiv.appendChild(buildModalInput(nestedPath, subSchema, subType, currentVal));
|
|
grid.appendChild(fieldDiv);
|
|
});
|
|
|
|
section.appendChild(grid);
|
|
body.appendChild(section);
|
|
} else {
|
|
// Flat property
|
|
const hiddenInput = advancedCell.querySelector(`[data-nested-prop="${propName}"]`);
|
|
const currentVal = hiddenInput ? hiddenInput.value : (propSchema.default !== undefined ? propSchema.default : '');
|
|
|
|
const fieldDiv = document.createElement('div');
|
|
const _flatLbl = document.createElement('label');
|
|
_flatLbl.className = 'block text-sm font-medium text-gray-700 mb-1';
|
|
_flatLbl.title = desc;
|
|
_flatLbl.textContent = label;
|
|
fieldDiv.appendChild(_flatLbl);
|
|
fieldDiv.appendChild(buildModalInput(propName, propSchema, propType, currentVal));
|
|
body.appendChild(fieldDiv);
|
|
}
|
|
});
|
|
|
|
dialog.appendChild(body);
|
|
|
|
// Footer
|
|
const footer = document.createElement('div');
|
|
footer.className = 'flex justify-end gap-3 px-5 py-4 border-t border-gray-200 bg-gray-50 rounded-b-lg';
|
|
safeSetHTML(footer, `
|
|
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
|
class="px-4 py-2 text-sm text-gray-700 border border-gray-300 rounded-md hover:bg-gray-100">Cancel</button>
|
|
<button type="button" id="array-row-editor-save"
|
|
class="px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md">Save</button>`);
|
|
|
|
// Save handler
|
|
footer.querySelector('#array-row-editor-save').onclick = function() {
|
|
body.querySelectorAll('[data-modal-prop]').forEach(el => {
|
|
const propPath = el.dataset.modalProp;
|
|
const targetInput = advancedCell.querySelector(`[data-nested-prop="${propPath}"]`);
|
|
if (!targetInput) return;
|
|
if (el.type === 'checkbox') {
|
|
targetInput.value = el.checked ? 'true' : 'false';
|
|
} else {
|
|
targetInput.value = el.value;
|
|
}
|
|
});
|
|
window.closeArrayTableRowEditor();
|
|
};
|
|
|
|
dialog.appendChild(footer);
|
|
overlay.appendChild(dialog);
|
|
document.body.appendChild(overlay);
|
|
|
|
if (window.LEDDialog) {
|
|
rowEditorRelease = window.LEDDialog.trap(dialog, {
|
|
labelledBy: 'array-row-editor-title',
|
|
initialFocus: '[data-modal-prop]',
|
|
onEscape: window.closeArrayTableRowEditor
|
|
});
|
|
}
|
|
};
|
|
|
|
// Focus-trap release for the open row editor (see utils/dialog.js).
|
|
let rowEditorRelease = null;
|
|
|
|
window.closeArrayTableRowEditor = function() {
|
|
const modal = document.getElementById('array-row-editor-modal');
|
|
if (modal) modal.remove();
|
|
if (rowEditorRelease) {
|
|
const release = rowEditorRelease;
|
|
rowEditorRelease = null;
|
|
release();
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Build a single form control for the row editor modal.
|
|
*/
|
|
function buildModalInput(propPath, schema, propType, currentVal) {
|
|
const xWidget = schema['x-widget'] || schema['x_widget'];
|
|
const enumVals = schema.enum;
|
|
const wrap = document.createElement('div');
|
|
|
|
if (propType === 'boolean') {
|
|
const cb = document.createElement('input');
|
|
cb.type = 'checkbox';
|
|
cb.className = 'h-4 w-4 text-blue-600';
|
|
cb.checked = currentVal === 'true' || currentVal === true || currentVal === 1;
|
|
cb.dataset.modalProp = propPath;
|
|
wrap.appendChild(cb);
|
|
return wrap;
|
|
}
|
|
|
|
// Array[3] with x-widget color-picker → R/G/B row
|
|
if ((propType === 'array' || xWidget === 'color-picker') &&
|
|
(schema.minItems === 3 || schema.maxItems === 3 || xWidget === 'color-picker')) {
|
|
const parts = currentVal ? String(currentVal).split(',').map(s => s.trim()) : ['', '', ''];
|
|
const rVal = parts[0] || '';
|
|
const gVal = parts[1] || '';
|
|
const bVal = parts[2] || '';
|
|
|
|
// Hex color picker for visual selection
|
|
const hexVal = (rVal && gVal && bVal)
|
|
? '#' + [rVal, gVal, bVal].map(n => parseInt(n, 10).toString(16).padStart(2, '0')).join('')
|
|
: '#ffffff';
|
|
|
|
const colorRow = document.createElement('div');
|
|
colorRow.className = 'flex items-center gap-2 flex-wrap';
|
|
|
|
const colorPick = document.createElement('input');
|
|
colorPick.type = 'color';
|
|
colorPick.value = hexVal;
|
|
colorPick.className = 'h-8 w-10 cursor-pointer rounded border';
|
|
colorRow.appendChild(colorPick);
|
|
|
|
['R', 'G', 'B'].forEach((ch, i) => {
|
|
const lbl = document.createElement('label');
|
|
lbl.className = 'text-xs text-gray-500';
|
|
lbl.textContent = ch;
|
|
const numInp = document.createElement('input');
|
|
numInp.type = 'number';
|
|
numInp.min = '0';
|
|
numInp.max = '255';
|
|
numInp.step = '1';
|
|
numInp.value = [rVal, gVal, bVal][i];
|
|
numInp.className = 'w-14 px-1 py-1 border border-gray-300 rounded text-sm text-center';
|
|
numInp.dataset.colorChannel = i;
|
|
colorRow.appendChild(lbl);
|
|
colorRow.appendChild(numInp);
|
|
});
|
|
|
|
// Hidden aggregate input that the save handler reads
|
|
const agg = document.createElement('input');
|
|
agg.type = 'hidden';
|
|
agg.value = `${rVal},${gVal},${bVal}`;
|
|
agg.dataset.modalProp = propPath;
|
|
colorRow.appendChild(agg);
|
|
|
|
// Sync: color picker → R/G/B numbers + agg
|
|
colorPick.oninput = function() {
|
|
const hex = colorPick.value;
|
|
const r = parseInt(hex.slice(1,3), 16);
|
|
const g = parseInt(hex.slice(3,5), 16);
|
|
const b = parseInt(hex.slice(5,7), 16);
|
|
const nums = colorRow.querySelectorAll('input[data-color-channel]');
|
|
if (nums[0]) nums[0].value = r;
|
|
if (nums[1]) nums[1].value = g;
|
|
if (nums[2]) nums[2].value = b;
|
|
agg.value = `${r},${g},${b}`;
|
|
};
|
|
|
|
// Sync: R/G/B numbers → color picker + agg
|
|
colorRow.querySelectorAll('input[data-color-channel]').forEach(inp => {
|
|
inp.oninput = function() {
|
|
const nums = colorRow.querySelectorAll('input[data-color-channel]');
|
|
const r = parseInt(nums[0] ? nums[0].value : 0, 10) || 0;
|
|
const g = parseInt(nums[1] ? nums[1].value : 0, 10) || 0;
|
|
const b = parseInt(nums[2] ? nums[2].value : 0, 10) || 0;
|
|
colorPick.value = '#' + [r,g,b].map(n => n.toString(16).padStart(2,'0')).join('');
|
|
agg.value = `${r},${g},${b}`;
|
|
};
|
|
});
|
|
|
|
wrap.appendChild(colorRow);
|
|
return wrap;
|
|
}
|
|
|
|
if (Array.isArray(enumVals) && enumVals.length > 0) {
|
|
const sel = document.createElement('select');
|
|
sel.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm bg-white';
|
|
sel.dataset.modalProp = propPath;
|
|
enumVals.forEach(opt => {
|
|
if (opt === null) return;
|
|
const o = document.createElement('option');
|
|
o.value = opt; o.textContent = enumOptionLabel(schema, opt);
|
|
if (String(currentVal) === String(opt)) o.selected = true;
|
|
sel.appendChild(o);
|
|
});
|
|
wrap.appendChild(sel);
|
|
return wrap;
|
|
}
|
|
|
|
if (xWidget === 'date-picker') {
|
|
const inp = document.createElement('input');
|
|
inp.type = 'date';
|
|
inp.value = currentVal || '';
|
|
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
|
inp.dataset.modalProp = propPath;
|
|
wrap.appendChild(inp);
|
|
return wrap;
|
|
}
|
|
|
|
if (xWidget === 'time-picker') {
|
|
const inp = document.createElement('input');
|
|
inp.type = 'time';
|
|
inp.value = currentVal || '00:00';
|
|
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
|
inp.dataset.modalProp = propPath;
|
|
wrap.appendChild(inp);
|
|
return wrap;
|
|
}
|
|
|
|
if (propType === 'integer' || propType === 'number') {
|
|
const inp = document.createElement('input');
|
|
inp.type = 'number';
|
|
inp.value = currentVal !== '' && currentVal !== null ? currentVal : '';
|
|
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
|
inp.dataset.modalProp = propPath;
|
|
if (schema.minimum !== undefined) inp.min = schema.minimum;
|
|
if (schema.maximum !== undefined) inp.max = schema.maximum;
|
|
inp.step = propType === 'integer' ? '1' : 'any';
|
|
if (schema.description) inp.placeholder = schema.description;
|
|
wrap.appendChild(inp);
|
|
return wrap;
|
|
}
|
|
|
|
// Default: text
|
|
const inp = document.createElement('input');
|
|
inp.type = 'text';
|
|
inp.value = currentVal || '';
|
|
inp.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
|
inp.dataset.modalProp = propPath;
|
|
if (schema.description) inp.placeholder = schema.description;
|
|
wrap.appendChild(inp);
|
|
return wrap;
|
|
}
|
|
|
|
|
|
// ─── In-cell image upload ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Called from file-upload-single cells inside array-table rows.
|
|
* Uploads the selected file and updates the path text input.
|
|
*/
|
|
window.handleArrayTableImageUpload = async function(event, pathInput, previewImg, pluginId) {
|
|
const file = event.target.files && event.target.files[0];
|
|
if (!file) return;
|
|
|
|
const notifyFn = window.showNotification || console.log;
|
|
const allowed = ['image/png', 'image/jpeg', 'image/bmp', 'image/gif'];
|
|
if (!allowed.includes(file.type)) {
|
|
notifyFn(`File type "${file.type}" not allowed`, 'error');
|
|
return;
|
|
}
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
notifyFn('File exceeds 5MB limit', 'error');
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('plugin_id', pluginId);
|
|
formData.append('files', file);
|
|
|
|
try {
|
|
const resp = await fetch('/api/v3/plugins/assets/upload', { method: 'POST', body: formData });
|
|
if (!resp.ok) throw new Error(`Server error ${resp.status}`);
|
|
const data = await resp.json();
|
|
if (data.status === 'success' && data.uploaded_files && data.uploaded_files[0]) {
|
|
const path = data.uploaded_files[0].path;
|
|
pathInput.value = path;
|
|
if (previewImg) { previewImg.src = '/' + path; previewImg.style.display = 'inline'; }
|
|
notifyFn('Image uploaded', 'success');
|
|
} else {
|
|
throw new Error(data.message || 'Upload failed');
|
|
}
|
|
} catch (err) {
|
|
notifyFn('Upload error: ' + err.message, 'error');
|
|
} finally {
|
|
event.target.value = '';
|
|
}
|
|
};
|
|
|
|
// ─── Button helpers ──────────────────────────────────────────────────────
|
|
|
|
function updateAddButtonState(fieldId) {
|
|
const tbody = document.getElementById(fieldId + '_tbody');
|
|
const addButton = document.querySelector(`button[data-field-id="${fieldId}"]`);
|
|
if (!tbody || !addButton) return;
|
|
const maxItems = parseInt(addButton.getAttribute('data-max-items'), 10);
|
|
const currentRows = tbody.querySelectorAll('.array-table-row').length;
|
|
const isAtMax = currentRows >= maxItems;
|
|
addButton.disabled = isAtMax;
|
|
addButton.style.opacity = isAtMax ? '0.5' : '';
|
|
}
|
|
|
|
window.updateArrayTableAddButtonState = updateAddButtonState;
|
|
|
|
window.addArrayTableRow = function(button) {
|
|
const fieldId = button.getAttribute('data-field-id');
|
|
const fullKey = button.getAttribute('data-full-key');
|
|
const maxItems = parseInt(button.getAttribute('data-max-items'), 10);
|
|
const pluginId = button.getAttribute('data-plugin-id');
|
|
|
|
let itemProperties = {};
|
|
let displayColumns = [];
|
|
let fullItemProperties = {};
|
|
|
|
try { itemProperties = JSON.parse(button.getAttribute('data-item-properties') || '{}'); } catch(_e) {}
|
|
try { displayColumns = JSON.parse(button.getAttribute('data-display-columns') || '[]'); } catch(_e) {}
|
|
try { fullItemProperties = JSON.parse(button.getAttribute('data-full-item-properties') || '{}'); } catch(_e) { fullItemProperties = itemProperties; }
|
|
|
|
const tbody = document.getElementById(fieldId + '_tbody');
|
|
if (!tbody) return;
|
|
|
|
const currentRows = tbody.querySelectorAll('.array-table-row').length;
|
|
if (currentRows >= maxItems) {
|
|
(window.showNotification || alert)(`Maximum ${maxItems} items allowed`, 'error');
|
|
return;
|
|
}
|
|
|
|
const newIndex = currentRows;
|
|
const row = createArrayTableRow(fieldId, fullKey, newIndex, pluginId, {}, itemProperties, displayColumns, fullItemProperties);
|
|
tbody.appendChild(row);
|
|
updateAddButtonState(fieldId);
|
|
};
|
|
|
|
window.removeArrayTableRow = function(button) {
|
|
const row = button.closest('tr');
|
|
if (!row) return;
|
|
if (!confirm('Remove this item?')) return;
|
|
|
|
const tbody = row.parentElement;
|
|
if (!tbody) return;
|
|
const fieldId = tbody.id.replace('_tbody', '');
|
|
row.remove();
|
|
|
|
// Re-index remaining rows
|
|
tbody.querySelectorAll('.array-table-row').forEach((r, index) => {
|
|
r.setAttribute('data-index', index);
|
|
r.querySelectorAll('input, select').forEach(el => {
|
|
const name = el.getAttribute('name');
|
|
if (name) el.setAttribute('name', name.replace(/\.\d+\./, '.' + index + '.'));
|
|
// Also update data-nested-prop-based inputs (they don't have regular names needing re-index)
|
|
});
|
|
});
|
|
|
|
updateAddButtonState(fieldId);
|
|
};
|
|
|
|
function initArrayTableButtons() {
|
|
document.querySelectorAll('button[data-field-id][data-max-items]').forEach(button => {
|
|
updateAddButtonState(button.getAttribute('data-field-id'));
|
|
});
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', initArrayTableButtons);
|
|
} else {
|
|
initArrayTableButtons();
|
|
}
|
|
})();
|