fix(web): resolve file upload config lookup for server-rendered forms (#279)

* fix(web): resolve file upload config lookup for server-rendered forms

The file upload widget's getUploadConfig() function failed to map
server-rendered field IDs (e.g., "static-image-images") back to schema
property keys ("images"), causing upload config (plugin_id, endpoint,
allowed_types) to be lost. This could prevent image uploads from
working correctly in the static-image plugin and others.

Changes:
- Add data-* attributes to the Jinja2 file-upload template so upload
  config is embedded directly on the file input element
- Update getUploadConfig() in both file-upload.js and plugins_manager.js
  to read config from data attributes first, falling back to schema lookup
- Remove duplicate handleFiles/handleFileDrop/handleFileSelect from
  plugins_manager.js that overwrote the more robust file-upload.js versions
- Bump cache-busting version strings so browsers fetch updated JS

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web): harden file upload functions against CodeRabbit patterns

- Add response.ok guard before response.json() in handleFiles,
  deleteUploadedFile, and handleCredentialsUpload to prevent
  SyntaxError on non-JSON error responses (PR #271 finding)
- Remove duplicate getUploadConfig() from plugins_manager.js;
  file-upload.js now owns this function exclusively
- Replace innerHTML with textContent/DOM methods in
  handleCredentialsUpload to prevent XSS (PR #271 finding)
- Fix redundant if-check in getUploadConfig data-attribute reader

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web): address CodeRabbit findings on file upload widget

- Add data-multiple="true" discriminator on array file inputs so
  handleFileDrop routes multi-file drops to handleFiles() not
  handleSingleFileUpload()
- Duplicate upload config data attributes onto drop zone wrapper so
  getUploadConfig() survives progress-helper DOM re-renders that
  remove the file input element
- Clear file input in finally block after credentials upload to allow
  re-selecting the same file on retry
- Branch deleteUploadedFile on fileType: JSON deletes remove the DOM
  element directly instead of routing through updateImageList() which
  renders image-specific cards (thumbnails, scheduling controls)

Addresses CodeRabbit findings on PR #279:
- Major: drag-and-drop hits single-file path for array uploaders
- Major: config lookup fails after first upload (DOM node removed)
- Minor: same-file retry silently no-ops
- Major: JSON deletes re-render list as images

Co-Authored-By: 5ymb01 <5ymb01@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web): address CodeRabbit round-2 findings on file upload widget

- Extract getConfigSourceElement() helper so handleFileDrop,
  handleSingleFileUpload, and getUploadConfig all share the same
  fallback logic: file input → drop zone wrapper
- Remove pluginId gate from getUploadConfig Strategy 1 — fields with
  uploadEndpoint or fileType but no pluginId now return config instead
  of falling through to generic defaults
- Fix JSON delete identifier mismatch: use file.id || file.category_name
  (matching the renderer at line 3202) instead of f.file_id; remove
  regex sanitization on DOM id lookup (renderer doesn't sanitize)

Addresses CodeRabbit round-2 findings on PR #279:
- Major: single-file uploads bypass drop-zone config fallback
- Major: getUploadConfig gated on data-plugin-id only
- Major: JSON delete file identifier mismatch vs renderer

Co-Authored-By: 5ymb01 <5ymb01@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(web): align delete handler file identifier with renderer logic

Remove f.file_id from JSON file delete filter to match the renderer's
identifier logic (file.id || file.category_name || idx). Prevents
deleted entries from persisting in the hidden input on next save.

Co-Authored-By: 5ymb01 <noreply@github.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: 5ymb01 <5ymb01@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: 5ymb01 <noreply@github.com>
This commit is contained in:
5ymb01
2026-03-25 12:57:04 -04:00
committed by GitHub
parent 48ff624a85
commit 81a022dbe8
4 changed files with 155 additions and 187 deletions

View File

@@ -72,9 +72,10 @@
event.preventDefault();
const files = event.dataTransfer.files;
if (files.length === 0) return;
// Route to single-file handler if this is a string file-upload widget
const fileInput = document.getElementById(`${fieldId}_file_input`);
if (fileInput && fileInput.dataset.uploadEndpoint && fileInput.dataset.uploadEndpoint.trim() !== '') {
// Route to single-file handler only for non-multiple string file-upload widgets
const configEl = getConfigSourceElement(fieldId);
const isMultiple = configEl && configEl.dataset.multiple === 'true';
if (!isMultiple && configEl && configEl.dataset.uploadEndpoint && configEl.dataset.uploadEndpoint.trim() !== '') {
window.handleSingleFileUpload(fieldId, files[0]);
} else {
window.handleFiles(fieldId, Array.from(files));
@@ -111,14 +112,33 @@
* @param {string} fieldId - Field ID
* @param {File} file - File to upload
*/
window.handleSingleFileUpload = async function(fieldId, file) {
/**
* Resolve the config source element for a field, checking file input first
* then falling back to the drop zone wrapper (which survives re-renders).
* @param {string} fieldId - Field ID
* @returns {HTMLElement|null} Element with data attributes, or null
*/
function getConfigSourceElement(fieldId) {
const fileInput = document.getElementById(`${fieldId}_file_input`);
if (!fileInput) return;
if (fileInput && (fileInput.dataset.pluginId || fileInput.dataset.uploadEndpoint)) {
return fileInput;
}
const dropZone = document.getElementById(`${fieldId}_drop_zone`);
if (dropZone && (dropZone.dataset.pluginId || dropZone.dataset.uploadEndpoint)) {
return dropZone;
}
return null;
}
const uploadEndpoint = fileInput.dataset.uploadEndpoint;
const targetFilename = fileInput.dataset.targetFilename || 'file.json';
const maxSizeMB = parseFloat(fileInput.dataset.maxSizeMb || '1');
const allowedExtensions = (fileInput.dataset.allowedExtensions || '.json')
window.handleSingleFileUpload = async function(fieldId, file) {
// Read config from file input or drop zone fallback (survives re-renders)
const configEl = getConfigSourceElement(fieldId);
if (!configEl) return;
const uploadEndpoint = configEl.dataset.uploadEndpoint;
const targetFilename = configEl.dataset.targetFilename || 'file.json';
const maxSizeMB = parseFloat(configEl.dataset.maxSizeMb || '1');
const allowedExtensions = (configEl.dataset.allowedExtensions || '.json')
.split(',').map(e => e.trim().toLowerCase());
const statusDiv = document.getElementById(`${fieldId}_upload_status`);
@@ -280,9 +300,14 @@
method: 'POST',
body: formData
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Server error ${response.status}: ${body}`);
}
const data = await response.json();
if (data.status === 'success') {
// Add uploaded files to current list
const currentFiles = window.getCurrentImages ? window.getCurrentImages(fieldId) : [];
@@ -348,9 +373,14 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody)
});
if (!response.ok) {
const body = await response.text();
throw new Error(`Server error ${response.status}: ${body}`);
}
const data = await response.json();
if (data.status === 'success') {
// Remove from current list - normalize types for comparison
const currentFiles = window.getCurrentImages ? window.getCurrentImages(fieldId) : [];
@@ -377,21 +407,42 @@
};
/**
* Get upload configuration from schema
* Get upload configuration for a file upload field.
* Priority: 1) data attributes on the file input element (server-rendered),
* 2) schema lookup via window.currentPluginConfig (client-rendered).
* @param {string} fieldId - Field ID
* @returns {Object} Upload configuration
*/
window.getUploadConfig = function(fieldId) {
// Extract config from schema
// Strategy 1: Read from data attributes on the file input element or
// the drop zone wrapper (which survives progress-helper re-renders).
// Accept any upload-related data attribute — not just pluginId.
const configSource = getConfigSourceElement(fieldId);
if (configSource) {
const ds = configSource.dataset;
const config = {};
if (ds.pluginId) config.plugin_id = ds.pluginId;
if (ds.uploadEndpoint) config.endpoint = ds.uploadEndpoint;
if (ds.fileType) config.file_type = ds.fileType;
if (ds.maxFiles) config.max_files = parseInt(ds.maxFiles, 10);
if (ds.maxSizeMb) config.max_size_mb = parseFloat(ds.maxSizeMb);
if (ds.allowedTypes) {
config.allowed_types = ds.allowedTypes.split(',').map(t => t.trim());
}
return config;
}
// Strategy 2: Extract config from schema (client-side rendered forms)
const schema = window.currentPluginConfig?.schema;
if (!schema || !schema.properties) return {};
// Find the property that matches this fieldId
// FieldId is like "image_config_images" for "image_config.images"
// FieldId is like "image_config_images" for "image_config.images" (client-side)
// or "static-image-images" for plugin "static-image", field "images" (server-side)
const key = fieldId.replace(/_/g, '.');
const keys = key.split('.');
let prop = schema.properties;
for (const k of keys) {
if (prop && prop[k]) {
prop = prop[k];
@@ -404,22 +455,22 @@
}
}
}
// If we found an array with x-widget, get its config
if (prop && prop.type === 'array' && prop['x-widget'] === 'file-upload') {
return prop['x-upload-config'] || {};
}
// Try to find nested images array
if (schema.properties && schema.properties.image_config &&
schema.properties.image_config.properties &&
// Try to find nested images array (legacy fallback)
if (schema.properties && schema.properties.image_config &&
schema.properties.image_config.properties &&
schema.properties.image_config.properties.images) {
const imagesProp = schema.properties.image_config.properties.images;
if (imagesProp['x-widget'] === 'file-upload') {
return imagesProp['x-upload-config'] || {};
}
}
return {};
};