mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-05-31 16:13:31 +00:00
feat(widgets): add plugin-file-manager, time-picker, file-upload-single widgets + array-table improvements
## New widgets ### plugin-file-manager (reusable) Inline file management UI driven entirely by x-widget-config in the plugin schema. Any plugin can adopt it by declaring web_ui_actions in manifest.json and adding x-widget: "plugin-file-manager" to their config schema. Features: - File card grid with enable/disable toggles, metadata (entry count, size, date) - Drag-and-drop + click upload zone with configurable hint text - Create file modal driven by create_fields schema config - Delete confirmation modal - Edit modal: auto-detects tabular data (object-of-objects) → paginated table with inline-editable cells and "Jump to today" navigation; falls back to JSON textarea for unstructured data - plugin_id auto-injected from template context; no per-plugin JS needed - Immediate saves via /api/v3/plugins/action — no Save Configuration required ### time-picker Wraps native <input type="time">, returns HH:MM string. Generic, zero config. ### file-upload-single Single-image upload for string fields. Shows thumbnail preview + clear button. plugin_id auto-injected from template context. ## New route (pages_v3.py) GET /v3/plugin-ui/<plugin_id>/web-ui/<filename> Serves a plugin's web_ui/ HTML fragment as a standalone page, wrapping it with a minimal HTML page that injects window.PLUGIN_ID and loads Tailwind CSS. Enables the json-file-manager iframe fallback (Phase A) and future plugin UIs. ## plugin_config.html updates - json-file-manager: renders plugin's web_ui/file_manager.html in an iframe via the new /v3/plugin-ui/ route (Phase A compatibility) - plugin-file-manager: full inline widget registration - time-picker, file-upload-single: registered in widget elif chain - color-picker: wired for type:array (RGB triplet) fields — renders hex picker + R/G/B number inputs with bidirectional sync - Plugin Actions section: suppressed when schema has a file-manager widget or when all actions are marked ui_hidden in manifest - x-widget-config passed to all widgets in the init script block ## array-table.js improvements (v2.0.0) - enum fields → <select> dropdown instead of plain text - date-picker x-widget → <input type=date> - time-picker x-widget → <input type=time> - file-upload-single x-widget → path input + upload button + thumbnail - Row edit modal (⚙) for non-displayed nested properties (layout, style objects) with color pickers, enum selects, number inputs - getValue() collects <select> values and nested key paths - Inline image upload via handleArrayTableImageUpload() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,21 +5,14 @@
|
||||
* Handles adding, removing, and editing array items with object properties.
|
||||
* Reads column definitions from the schema's items.properties.
|
||||
*
|
||||
* Usage in config_schema.json:
|
||||
* "my_array": {
|
||||
* "type": "array",
|
||||
* "x-widget": "array-table",
|
||||
* "x-columns": ["name", "code", "priority", "enabled"], // optional
|
||||
* "items": {
|
||||
* "type": "object",
|
||||
* "properties": {
|
||||
* "name": { "type": "string" },
|
||||
* "code": { "type": "string" },
|
||||
* "priority": { "type": "integer", "default": 50 },
|
||||
* "enabled": { "type": "boolean", "default": true }
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
* 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
|
||||
*/
|
||||
@@ -27,18 +20,16 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Ensure LEDMatrixWidgets registry exists
|
||||
if (typeof window.LEDMatrixWidgets === 'undefined') {
|
||||
console.error('[ArrayTableWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the array-table widget
|
||||
*/
|
||||
// ─── Widget registration ────────────────────────────────────────────────
|
||||
|
||||
window.LEDMatrixWidgets.register('array-table', {
|
||||
name: 'Array Table Widget',
|
||||
version: '1.0.0',
|
||||
version: '2.0.0',
|
||||
|
||||
render: function(container, config, value, options) {
|
||||
console.log('[ArrayTableWidget] Render called (server-side rendered)');
|
||||
@@ -53,24 +44,39 @@
|
||||
|
||||
rows.forEach((row) => {
|
||||
const item = {};
|
||||
row.querySelectorAll('input').forEach(input => {
|
||||
const name = input.getAttribute('name');
|
||||
if (!name || name.endsWith('.enabled') || input.type === 'hidden') return;
|
||||
const match = name.match(/\.\d+\.([^.]+)$/);
|
||||
if (match) {
|
||||
const propName = match[1];
|
||||
if (input.type === 'checkbox') {
|
||||
item[propName] = input.checked;
|
||||
} else if (input.type === 'number') {
|
||||
item[propName] = input.value ? parseFloat(input.value) : null;
|
||||
} else {
|
||||
item[propName] = input.value;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
if (Object.keys(item).length > 0) items.push(item);
|
||||
});
|
||||
|
||||
return items;
|
||||
@@ -81,236 +87,711 @@
|
||||
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) {
|
||||
console.warn(`[ArrayTableWidget] tbody not found for fieldId: ${fieldId}`);
|
||||
return;
|
||||
}
|
||||
if (!tbody) return;
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const row = createArrayTableRow(
|
||||
fieldId,
|
||||
options.fullKey,
|
||||
index,
|
||||
options.pluginId,
|
||||
item,
|
||||
options.itemProperties || {},
|
||||
options.displayColumns || []
|
||||
fieldId, options.fullKey, index, options.pluginId,
|
||||
item, options.itemProperties || {}, options.displayColumns || [],
|
||||
options.fullItemProperties || options.itemProperties || {}
|
||||
);
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
// Refresh Add button state after repopulating rows
|
||||
updateAddButtonState(fieldId);
|
||||
},
|
||||
|
||||
handlers: {}
|
||||
});
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function setNestedValue(obj, path, value) {
|
||||
const parts = path.split('.');
|
||||
let cur = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (cur[parts[i]] === undefined || typeof cur[parts[i]] !== 'object') {
|
||||
cur[parts[i]] = {};
|
||||
}
|
||||
cur = cur[parts[i]];
|
||||
}
|
||||
cur[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
function getNestedValue(obj, path) {
|
||||
return path.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj);
|
||||
}
|
||||
|
||||
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 ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a table row element for array item
|
||||
* Create one <td> for a display column.
|
||||
*/
|
||||
function createArrayTableRow(fieldId, fullKey, index, pluginId, item, itemProperties, displayColumns) {
|
||||
item = item || {};
|
||||
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
|
||||
const hidden = document.createElement('input');
|
||||
hidden.type = 'hidden';
|
||||
hidden.name = inputName;
|
||||
hidden.value = 'false';
|
||||
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';
|
||||
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 = 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]) => {
|
||||
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);
|
||||
|
||||
// Visible column cells
|
||||
displayColumns.forEach(colName => {
|
||||
const colDef = itemProperties[colName] || {};
|
||||
const colType = colDef.type || 'string';
|
||||
const colDefault = colDef.default !== undefined ? colDef.default : (colType === 'boolean' ? false : '');
|
||||
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;
|
||||
|
||||
const cell = document.createElement('td');
|
||||
cell.className = 'px-4 py-3 whitespace-nowrap';
|
||||
|
||||
if (colType === 'boolean') {
|
||||
const hiddenInput = document.createElement('input');
|
||||
hiddenInput.type = 'hidden';
|
||||
hiddenInput.name = `${fullKey}.${index}.${colName}`;
|
||||
hiddenInput.value = 'false';
|
||||
cell.appendChild(hiddenInput);
|
||||
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.name = `${fullKey}.${index}.${colName}`;
|
||||
checkbox.checked = Boolean(colValue);
|
||||
checkbox.value = 'true';
|
||||
checkbox.className = 'h-4 w-4 text-blue-600';
|
||||
cell.appendChild(checkbox);
|
||||
} else if (colType === 'integer' || colType === 'number') {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.name = `${fullKey}.${index}.${colName}`;
|
||||
input.value = colValue !== null && colValue !== undefined ? colValue : '';
|
||||
if (colDef.minimum !== undefined) input.min = colDef.minimum;
|
||||
if (colDef.maximum !== undefined) input.max = colDef.maximum;
|
||||
input.step = colType === 'integer' ? '1' : 'any';
|
||||
input.className = 'block w-20 px-2 py-1 border border-gray-300 rounded text-sm text-center';
|
||||
if (colDef.description) input.title = colDef.description;
|
||||
cell.appendChild(input);
|
||||
} else {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.name = `${fullKey}.${index}.${colName}`;
|
||||
input.value = colValue !== null && colValue !== undefined ? colValue : '';
|
||||
input.className = 'block w-full px-2 py-1 border border-gray-300 rounded text-sm';
|
||||
if (colDef.description) input.placeholder = colDef.description;
|
||||
if (colDef.pattern) input.pattern = colDef.pattern;
|
||||
if (colDef.minLength) input.minLength = colDef.minLength;
|
||||
if (colDef.maxLength) input.maxLength = colDef.maxLength;
|
||||
cell.appendChild(input);
|
||||
}
|
||||
|
||||
row.appendChild(cell);
|
||||
row.appendChild(createCell(fullKey, index, colName, colDef, colValue, pluginId));
|
||||
});
|
||||
|
||||
// Determine non-displayed properties (these go into the advanced cell + edit modal)
|
||||
const nonDisplayed = {};
|
||||
Object.keys(fullItemProperties).forEach(k => {
|
||||
if (!displayColumns.includes(k) && k !== 'id') {
|
||||
nonDisplayed[k] = fullItemProperties[k];
|
||||
}
|
||||
});
|
||||
const hasAdvanced = Object.keys(nonDisplayed).length > 0;
|
||||
|
||||
// Actions cell
|
||||
const actionsCell = document.createElement('td');
|
||||
actionsCell.className = 'px-4 py-3 whitespace-nowrap text-center';
|
||||
const removeButton = document.createElement('button');
|
||||
removeButton.type = 'button';
|
||||
removeButton.className = 'text-red-600 hover:text-red-800 px-2 py-1';
|
||||
removeButton.onclick = function() { window.removeArrayTableRow(this); };
|
||||
const removeIcon = document.createElement('i');
|
||||
removeIcon.className = 'fas fa-trash';
|
||||
removeButton.appendChild(removeIcon);
|
||||
actionsCell.appendChild(removeButton);
|
||||
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.innerHTML = '<i class="fas fa-trash"></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.innerHTML = '<i class="fas fa-sliders-h"></i>';
|
||||
actionsCell.appendChild(editBtn);
|
||||
}
|
||||
|
||||
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 || '{}');
|
||||
const tbody = row.closest('tbody');
|
||||
const fieldId = tbody ? tbody.id.replace('_tbody', '') : '';
|
||||
const rowIndex = parseInt(row.dataset.index, 10);
|
||||
|
||||
// Close any existing modal
|
||||
const existing = document.getElementById('array-row-editor-modal');
|
||||
if (existing) existing.remove();
|
||||
|
||||
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
|
||||
dialog.innerHTML = `
|
||||
<div class="flex items-center justify-between px-5 py-4 border-b border-gray-200">
|
||||
<h3 class="text-base font-semibold text-gray-900">Advanced Properties</h3>
|
||||
<button type="button" onclick="window.closeArrayTableRowEditor()"
|
||||
class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></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';
|
||||
section.innerHTML = `<h4 class="text-sm font-medium text-gray-700 mb-3">${escapeHtml(label)}</h4>`;
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'grid grid-cols-2 gap-3';
|
||||
|
||||
Object.entries(propSchema.properties).forEach(([subName, subSchema]) => {
|
||||
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');
|
||||
fieldDiv.innerHTML = `<label class="block text-xs font-medium text-gray-600 mb-1" title="${escapeHtml(subDesc)}">${escapeHtml(subLabel)}</label>`;
|
||||
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');
|
||||
fieldDiv.innerHTML = `<label class="block text-sm font-medium text-gray-700 mb-1" title="${escapeHtml(desc)}">${escapeHtml(label)}</label>`;
|
||||
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';
|
||||
footer.innerHTML = `
|
||||
<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);
|
||||
};
|
||||
|
||||
window.closeArrayTableRowEditor = function() {
|
||||
const modal = document.getElementById('array-row-editor-modal');
|
||||
if (modal) modal.remove();
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the Add button's disabled state based on current row count
|
||||
* @param {string} fieldId - Field ID to find the tbody and button
|
||||
* Build a single form control for the row editor modal.
|
||||
*/
|
||||
function updateAddButtonState(fieldId) {
|
||||
const tbody = document.getElementById(fieldId + '_tbody');
|
||||
if (!tbody) return;
|
||||
function buildModalInput(propPath, schema, propType, currentVal) {
|
||||
const xWidget = schema['x-widget'] || schema['x_widget'];
|
||||
const enumVals = schema.enum;
|
||||
const wrap = document.createElement('div');
|
||||
|
||||
// Find the add button by looking for the button with matching data-field-id
|
||||
const addButton = document.querySelector(`button[data-field-id="${fieldId}"]`);
|
||||
if (!addButton) return;
|
||||
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;
|
||||
}
|
||||
|
||||
const maxItems = parseInt(addButton.getAttribute('data-max-items'), 10);
|
||||
const currentRows = tbody.querySelectorAll('.array-table-row');
|
||||
const isAtMax = currentRows.length >= maxItems;
|
||||
// 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] || '';
|
||||
|
||||
addButton.disabled = isAtMax;
|
||||
addButton.style.opacity = isAtMax ? '0.5' : '';
|
||||
// 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 = 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;
|
||||
}
|
||||
|
||||
// Expose for external use if needed
|
||||
window.updateArrayTableAddButtonState = updateAddButtonState;
|
||||
function escapeHtml(str) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = String(str || '');
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// ─── In-cell image upload ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Add a new row to the array table
|
||||
* @param {HTMLElement} button - The button element with data attributes
|
||||
* Called from file-upload-single cells inside array-table rows.
|
||||
* Uploads the selected file and updates the path text input.
|
||||
*/
|
||||
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');
|
||||
window.handleArrayTableImageUpload = async function(event, pathInput, previewImg, pluginId) {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// Parse JSON with fallback on error
|
||||
let itemProperties = {};
|
||||
let displayColumns = [];
|
||||
const rawItemProps = button.getAttribute('data-item-properties') || '{}';
|
||||
const rawDisplayCols = button.getAttribute('data-display-columns') || '[]';
|
||||
|
||||
try {
|
||||
itemProperties = JSON.parse(rawItemProps);
|
||||
} catch (e) {
|
||||
console.error('[ArrayTableWidget] Failed to parse data-item-properties:', rawItemProps, e);
|
||||
itemProperties = {};
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
displayColumns = JSON.parse(rawDisplayCols);
|
||||
} catch (e) {
|
||||
console.error('[ArrayTableWidget] Failed to parse data-display-columns:', rawDisplayCols, e);
|
||||
displayColumns = [];
|
||||
}
|
||||
|
||||
const tbody = document.getElementById(fieldId + '_tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
const currentRows = tbody.querySelectorAll('.array-table-row');
|
||||
if (currentRows.length >= maxItems) {
|
||||
const notifyFn = window.showNotification || alert;
|
||||
notifyFn(`Maximum ${maxItems} items allowed`, 'error');
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
notifyFn('File exceeds 5MB limit', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const newIndex = currentRows.length;
|
||||
const row = createArrayTableRow(fieldId, fullKey, newIndex, pluginId, {}, itemProperties, displayColumns);
|
||||
tbody.appendChild(row);
|
||||
const formData = new FormData();
|
||||
formData.append('plugin_id', pluginId);
|
||||
formData.append('files', file);
|
||||
|
||||
// Update button state after adding
|
||||
updateAddButtonState(fieldId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove a row from the array table
|
||||
* @param {HTMLElement} button - The remove button element
|
||||
*/
|
||||
window.removeArrayTableRow = function(button) {
|
||||
const row = button.closest('tr');
|
||||
if (!row) return;
|
||||
|
||||
if (confirm('Remove this item?')) {
|
||||
const tbody = row.parentElement;
|
||||
if (!tbody) return;
|
||||
|
||||
// Get fieldId from tbody id (format: {fieldId}_tbody)
|
||||
const fieldId = tbody.id.replace('_tbody', '');
|
||||
|
||||
row.remove();
|
||||
|
||||
// Re-index remaining rows
|
||||
const rows = tbody.querySelectorAll('.array-table-row');
|
||||
rows.forEach(function(r, index) {
|
||||
r.setAttribute('data-index', index);
|
||||
r.querySelectorAll('input').forEach(function(input) {
|
||||
const name = input.getAttribute('name');
|
||||
if (name) {
|
||||
input.setAttribute('name', name.replace(/\.\d+\./, '.' + index + '.'));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Update button state after removing
|
||||
updateAddButtonState(fieldId);
|
||||
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 = '';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize all array table add buttons on page load
|
||||
*/
|
||||
// ─── 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() {
|
||||
const addButtons = document.querySelectorAll('button[data-field-id][data-max-items]');
|
||||
addButtons.forEach(function(button) {
|
||||
const fieldId = button.getAttribute('data-field-id');
|
||||
updateAddButtonState(fieldId);
|
||||
document.querySelectorAll('button[data-field-id][data-max-items]').forEach(button => {
|
||||
updateAddButtonState(button.getAttribute('data-field-id'));
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initArrayTableButtons);
|
||||
} else {
|
||||
initArrayTableButtons();
|
||||
}
|
||||
|
||||
console.log('[ArrayTableWidget] Array table widget registered');
|
||||
console.log('[ArrayTableWidget] Array table widget registered (v2.0.0)');
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user