From 80d8479fd04c9a06a98f7cf4aedaa367984ac038 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sun, 08 Feb 2026 22:15:13 +0000
Subject: [PATCH] =Form.php upload render fix

---
 assets/js/concise/FormController.js |  345 +++++++++++++++++++++++++++++++++++----------------------
 1 files changed, 213 insertions(+), 132 deletions(-)

diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 9a3f632..bac1cd6 100644
--- a/assets/js/concise/FormController.js
+++ b/assets/js/concise/FormController.js
@@ -59,7 +59,7 @@
 				field: '.field',					//querySelectorAll
 				label: 'label',
 				success: '.success',
-				error: '.success',
+				error: '.error',
 				message: '.validation-message',
 			},
 			repeater: {
@@ -73,11 +73,12 @@
 			},
 			tagList: {
 				tagList: '.field.tag-list',			//querySelectorAll
-				input: '.tag-input-row',
+				input: '.row',
 				add: '.add-tag',
 				remove: '.remove-tag',
 				label: '.tag-label',
 				items: '.tag-items',
+				item: '.tag-item',
 				inputs: this.inputSelectors,				//querySelectorAll
 				value: 'input[type="hidden"]'		//querySelectorAll
 			},
@@ -91,7 +92,7 @@
 				input: 'input[type="number"]'
 			},
 			limits: {
-				hasLimit: '[data-limit]',
+				hasLimit: '[data-maxlength]',
 				limit: '.limit',
 				current: '.current',
 			}
@@ -325,6 +326,7 @@
 		if (e.target.closest('[data-ignore]') || this.isRestoring) return;
 
 		let field = this.getField(e.target);
+
 		//Dependencies
 		if (this.dependencies.has(field.dataset.field)) {
 			let dependency = this.dependencies.get(field.dataset.field);
@@ -333,6 +335,11 @@
 			});
 		}
 
+		if (field.dataset.fieldType === 'repeater' || field.dataset.fieldType === 'tag-list') {
+			this.updateCollectionField(field);
+			return;
+		}
+
 		let form = this.getForm(e.target);
 		this.updateItem(field.dataset.field, this.getFieldValue(e.target), form);
 	}
@@ -377,12 +384,12 @@
 
 		if (this.subscribers.size > 0) {
 			e.preventDefault();
-			console.log('Cancelling scheduled backup and manually backing up');
-			this.cancelBackup();
-			await this.backup();
-			const storedData = await this.store.get(form.id);
 
 			if (form.options.cache) {
+				this.cancelBackup();
+				await this.backup();
+				const storedData = await this.store.get(form.id);
+
 				this.notify('form-submit', {
 					config: form,
 					data: storedData.changes
@@ -605,8 +612,11 @@
 								this.removeQuantityListeners(item.element);
 								break;
 						}
+
+						if (check.has(item.id)) {
+							check.delete(item.id);
+						}
 					});
-					check.delete(item.id);
 				}
 			}
 
@@ -754,14 +764,14 @@
 					let conf = this.quantityFields.get(e.target.closest('[data-num-id]')?.dataset.numId);
 					if(!conf) return;
 					let change = 0;
-					if (conf.increase.contains(e.target)) {
+					if (conf.ui.increase.contains(e.target)) {
 						change++;
-					} else if (conf.decrease.contains(e.target)) {
+					} else if (conf.ui.decrease.contains(e.target)) {
 						change--;
 					}
 					if (change === 0) return;
 					let field = this.getField(e.target);
-					let step = conf.input.step;
+					let step = conf.ui.input.step;
 					step = Math.max(step, 1);
 					if (e.ctrlKey && e.shiftKey) {
 						step = step * 50;
@@ -770,23 +780,24 @@
 					} else if (e.shiftKey) {
 						step = step * 10;
 					}
-					let value = (conf.input.value === '') ? 0 : parseFloat(conf.input.value);
-					conf.input.value = (value + (step * change));
+					let value = (conf.ui.input.value === '') ? 0 : parseFloat(conf.ui.input.value);
+					conf.ui.input.value = (value + (step * change));
 
-					value = parseFloat(conf.input.value);
+					value = parseFloat(conf.ui.input.value);
 
-					if (conf.input.min && value < conf.input.min) {
-						conf.input.value = conf.input.min;
-						conf.decrease.disabled = true;
-					} else if (conf.input.max && value > conf.input.max) {
-						conf.input.value = conf.input.max;
-						conf.increase.disabled = true;
+					if (conf.ui.input.min && value < conf.ui.input.min) {
+						conf.ui.input.value = conf.ui.input.min;
+						conf.ui.decrease.disabled = true;
+					} else if (conf.ui.input.max && value > conf.ui.input.max) {
+						conf.ui.input.value = conf.ui.input.max;
+						conf.ui.increase.disabled = true;
 					} else {
-						if (conf.decrease.disabled) conf.decrease.disabled = false;
-						if (conf.increase.disabled) conf.increase.disabled = false;
+						if (conf.ui.decrease.disabled) conf.ui.decrease.disabled = false;
+						if (conf.ui.increase.disabled) conf.ui.increase.disabled = false;
 					}
 				}
 			checkForRepeaters(form) {
+
 				if (!form.querySelector(this.selectors.repeater.repeater)) return;
 
 				form.querySelectorAll(this.selectors.repeater.repeater).forEach(repeater => {
@@ -799,7 +810,7 @@
 						sortable: false,
 					};
 
-					if (!config.ui.addButton) return;
+					if (!config.ui.add) return;
 
 					let template = repeater.querySelector('template');
 					this.templates.define(
@@ -811,8 +822,10 @@
 							setup({el, refs, manyRefs, data}) {
 								let index = config.ui.items?.children?.length??0;
 								el.dataset.index = index;
+
+
 								manyRefs.inputs?.forEach(input => {
-									window.prefixInput(input, `${el.dataset.fieldName}:${index}:`)
+									window.prefixInput(input, `${data.repeater.dataset.field}:${index}:`, el);
 								});
 							}
 						},
@@ -843,11 +856,15 @@
 					if (e.target.matches(this.selectors.repeater.add)) {
 						this.addRepeaterRow(e.target.closest('[data-repeater-id]'));
 					} else if (e.target.matches(this.selectors.repeater.remove)) {
-						this.removeRepeaterRow(e.target);
+						this.removeRepeaterRow(e.target.closest('[data-index]'));
 					}
 				}
 				addRepeaterRow(repeater) {
-					repeater.append(this.templates.create(repeater.dataset.repeaterId));
+					let data = {};
+					data.repeater = repeater;
+					repeater.append(this.templates.create(repeater.dataset.repeaterId, data));
+					let form = this.getForm(repeater);
+					this.initializeFields(repeater, form);
 					this.a11y.announce('Row added');
 				}
 				removeRepeaterRow(row) {
@@ -868,6 +885,7 @@
 					if (!config.ui.input || !config.ui.add || !config.ui.items) return;
 
 					field.dataset.tagListId = config.id;
+					config.fieldName = field.dataset.field;
 
 					let template = field.querySelector('template');
 					this.templates.define(
@@ -883,7 +901,8 @@
 								let index = config.ui.items?.children?.length??0;
 								el.dataset.index = index;
 								manyRefs.inputs?.forEach(input => {
-									window.prefixInput(input, `${el.dataset.fieldName}:${index}:`)
+									let wrapper = input.closest('.tag-item');
+									window.prefixInput(input, `${data.fieldName}:${index}:`, wrapper)
 								});
 
 								if (refs.label) {
@@ -892,7 +911,8 @@
 							}
 						},
 					);
-
+					config.ui.inputs = Array.from(field.querySelectorAll(this.selectors.tagList.inputs));
+					config.ui.value = Array.from(field.querySelectorAll(this.selectors.tagList.value));
 					this.tagLists.set(config.id, config);
 					this.addTagListListeners(field);
 				});
@@ -900,91 +920,124 @@
 			}
 				addTagListListeners(el) {
 					el.addEventListener('click', this.tagListClick);
-					el.addEventListener('keypress', this.tagListInput, {passive: true})
+					el.addEventListener('keypress', this.tagListInput);
 				}
 				removeTagListListeners(el) {
 					el.removeEventListener('click', this.tagListClick);
-					el.removeEventListener('keypress', this.tagListInput)
+					el.removeEventListener('keypress', this.tagListInput);
 				}
 
 				handleTagListClick(e) {
-					if (e.target.matches(this.selectors.tagList.add)) {
+					if (window.targetCheck(e,this.selectors.tagList.add)) {
 						this.addTagListItem(e.target.closest('[data-tag-list-id]'));
-					} else if (e.target.matches(this.selectors.tagList.remove)) {
-						this.removeTagListItem(e.target.closest(this.selectors.tagList.remove));
+					} else if (window.targetCheck(e, this.selectors.tagList.remove)) {
+						this.removeTagListItem(e.target.closest(this.selectors.tagList.item));
 					}
 				}
-					addTagListItem(tagList) {
-						let config = this.tagLists.get(tagList.dataset.tagListId);
-						if (!config) return;
+			addTagListItem(tagList) {
+				let config = this.tagLists.get(tagList.dataset.tagListId);
+				if (!config) return;
 
-						let data = {};
-						let hasValue = false;
+				let data = {};
+				let hasValue = false;
+				let isValid = true;
 
-						for (let input of config.ui.inputs) {
-							this.validateField(input);
-							const fieldName = input.name.replace('new_','');
-							const value = this.getFieldValue(input);
-							if (value) hasValue = true;
-							data[fieldName] = value;
+				// First pass: validate all inputs
+				for (let input of config.ui.inputs) {
+					const isRequired = input.required || input.dataset.required === 'true';
+					const value = this.getFieldValue(input);
 
-							//clear values and validation
-							if (['checkbox', 'radio'].includes(input.type)) {
-								input.checked = false;
-							} else {
-								input.value = '';
+					if (value) hasValue = true;
+
+					// Validate and check for errors
+					const valid = this.validateField(input);
+
+					if (isRequired && !value) {
+						this.showError(input, 'This field is required');
+						isValid = false;
+					} else if (!valid) {
+						isValid = false;
+					}
+
+					const fieldName = input.name.replace('new_','');
+					data[fieldName] = value;
+				}
+
+				// Stop if validation failed
+				if (!isValid) {
+					this.a11y.announce('Please correct the errors before adding');
+					const firstInvalid = config.ui.inputs.find(input => {
+						const isRequired = input.required || input.dataset.required === 'true';
+						return (isRequired && !this.getFieldValue(input));
+					});
+					if (firstInvalid) firstInvalid.focus();
+					return;
+				}
+
+				if (!hasValue) {
+					this.a11y.announce('Please fill in at least one field');
+					config.ui.inputs[0].focus();
+					return;
+				}
+
+				// Build label
+				let label;
+				switch (config.format) {
+					case 'first_field':
+						label = Object.values(data)[0];
+						break;
+					case 'all_fields':
+						label = Object.values(data).join(', ');
+						break;
+					default:
+						if (config.format.includes('{')) {
+							label = config.format;
+							for (const [key, value] of Object.entries(data)) {
+								label = label.replace(`{${key}}`, value);
 							}
-							this.clearValidation(input);
+						} else {
+							label = data[config.format]??Object.values(data)[0];
 						}
+						break;
+				}
 
-						if (!hasValue) {
-							this.a11y.announce('Please fill in at least one field');
-							config.ui.inputs[0].focus();
-							return;
-						}
+				let newItem = this.templates.create(tagList.dataset.tagListId, {
+					label: label,
+					fieldName: config.fieldName
+				});
 
-						let label;
-						switch (config.format) {
-							case 'first_field':
-								label = Object.values(data)[0];
-								break;
-							case 'all_fields':
-								label = Object.values(data).join(', ');
-								break;
-							default:
-								if (format.includes('{')) {
-									let label = config.format;
-									for (const [key, value] of Object.entries(data)) {
-										label = label.replace(`{${key}}`, value);
-									}
-								} else {
-									label = data[config.format]??Object.values(data)[0];
-								}
-								break;
-						}
+				const index = config.ui.items?.children?.length ?? 0;
+				newItem?.querySelectorAll('input[type=hidden]')?.forEach(input => {
+					const fieldKey = input.dataset.field;
+					input.name = `${config.fieldName}:${index}:${fieldKey}`;
+					input.id = `${config.fieldName}:${index}:${fieldKey}`;
+					input.value = data[fieldKey] || '';
+				});
 
-						let newItem = this.templates.create(tagList.dataset.tagListId, {
-							label: label
-						});
+				config.ui.items.append(newItem);
 
-						const index = config.ui.items?.children?.length ?? 0;
-						newItem?.querySelectorAll('input[type=hidden]')?.forEach(input => {
-							const fieldKey = input.dataset.field;
-							input.name = `${config.element.field}:${index}:${fieldKey}`;
-							input.value = data[fieldKey] || '';
-						});
-
-						config.ui.items.append(newItem);
-						config.ui.inputs[0]?.focus();
-
-						this.a11y.announce('Item added');
+				// Clear inputs AFTER success
+				for (let input of config.ui.inputs) {
+					if (['checkbox', 'radio'].includes(input.type)) {
+						input.checked = false;
+					} else {
+						input.value = '';
 					}
-					removeTagListItem(tag) {
-						let tagList = tag.closest('[data-tag-list-id]');
-						tag.remove();
-						this.reindexList(tagList);
-						this.a11y.announce('Item removed');
-					}
+					this.clearValidation(input);
+				}
+
+				config.ui.inputs[0]?.focus();
+				this.updateCollectionField(tagList);
+				this.a11y.announce('Item added');
+			}
+				removeTagListItem(item) {
+					let tagList = item.closest('[data-tag-list-id]');
+					if (!tagList) return;
+					item.remove();
+					this.reindexList(tagList);
+					this.updateCollectionField(tagList);
+					this.a11y.announce('Item removed');
+				}
 				handleTagListInput(e) {
 					let target = e.target;
 					let field = target.closest('[data-tag-list-id]');
@@ -1077,25 +1130,32 @@
 						}
 					});
 				}
-			checkForCharacterLimits(form) {
-				if (!form.querySelector(this.selectors.limits.hasLimit)) return;
-				this.countUpdaters = this.updateCount.bind(this);
+	checkForCharacterLimits(form) {
+		if (!form.querySelector(this.selectors.limits.hasLimit)) return;
+		this.countUpdaters = this.updateCount.bind(this);
 
-				form.querySelectorAll(`${this.selectors.limits.hasLimit}`).forEach(input => {
-					let id = window.generateID('limit');
-					input.dataset.charLimitId = id;
-					let config = {
-						element: input,
-						form: form.dataset.formId,
-						ui: window.uiFromSelectors(this.selectors.limits, input.closest('.field'))
-					};
-					config.ui.limit.textContent = input.dataset.limit;
-					this.charLimits.set(id, config);
+		form.querySelectorAll(this.selectors.limits.hasLimit).forEach(field => {
+			const input = field.querySelector('input, textarea, select');
+			if (!input) return;
 
-					this.addCharacterLimitListeners(input);
-				});
+			let id = window.generateID('limit');
+			input.dataset.charLimitId = id;
+			input.dataset.limit = field.dataset.maxlength;
 
+			let config = {
+				element: input,
+				form: form.dataset.formId,
+				ui: window.uiFromSelectors(this.selectors.limits, field)
+			};
+
+			if (config.ui.limit) {
+				config.ui.limit.textContent = field.dataset.maxlength;
 			}
+
+			this.charLimits.set(id, config);
+			this.addCharacterLimitListeners(input);
+		});
+	}
 				addCharacterLimitListeners(input) {
 					input.addEventListener('input', this.countUpdaters, {passive: true});
 				}
@@ -1179,19 +1239,51 @@
 	 * @param {HTMLElement} container
 	 */
 	reindexList(container) {
+		const fieldName = container.dataset.field || container.dataset.repeaterId || container.dataset.tagListId;
+
 		Array.from(container.children).forEach((item, index) => {
 			item.dataset.index = `${index}`;
-			Array.from(item.children).forEach(child => {
-				if (child.type === 'hidden') {
-					window.prefixInput(
-						child,
-						`${container.dataset.field}:${index}:${child.dataset.field}`
-					);
-				}
+
+			// Find ALL inputs within this item, not just direct children
+			const inputs = item.querySelectorAll('input, select, textarea');
+
+			inputs.forEach(input => {
+				// Skip inputs that shouldn't be re-indexed (like file inputs)
+				if (input.type === 'file') return;
+
+				// Get the field name from the input's data-field or name
+				const inputField = input.dataset.field || input.name.split(':').pop();
+
+				// Re-prefix with the new index, passing item as wrapper
+				window.prefixInput(
+					input,
+					`${fieldName}:${index}:`,
+					item  // Pass the item as wrapper for label lookup
+				);
 			});
 		});
 
-		//schedule save
+
+		this.updateCollectionField(container);
+	}
+
+	/**
+	 * Update the entire repeater/tagList field data
+	 * Call this whenever rows are added, removed, or reordered
+	 */
+	updateCollectionField(element) {
+		const field = element.closest('[data-field]');
+		if (!field) return;
+
+		const fieldType = field.dataset.fieldType;
+		if (!['repeater', 'tag-list'].includes(fieldType)) return;
+
+		const form = this.getForm(element);
+		if (!form) return;
+
+		// Get all current data for the collection
+		const value = this.getFieldValue(field.querySelector('input, select, textarea'));
+		this.updateItem(field.dataset.field, value, form);
 	}
 	/**********************************************************************
 	 VALIDATION
@@ -1343,28 +1435,20 @@
 		if (data.field) {
 			const fieldWrapper = form.querySelector(`[data-field="${data.field}"]`);
 			if (fieldWrapper) {
-				// Use existing showError method for consistency
 				this.showError(fieldWrapper, data.message);
 
-				// Mark as touched so validation persists
-				this.touchedFields.add(data.field);
-
-				// Scroll to error
 				fieldWrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
 
-				// Focus the input for better UX
 				const input = fieldWrapper.querySelector('input, textarea, select');
 				if (input) {
 					input.focus();
 				}
 			}
 		} else {
-			// General form error (not field-specific)
 			const error = document.createElement('div');
 			error.className = 'form-error error-message';
 			error.textContent = data.message;
 
-			// Add icon for consistency
 			const icon = window.getIcon?.('close-circle');
 			if (icon) {
 				icon.classList.add('error-icon');
@@ -1372,12 +1456,9 @@
 			}
 
 			form.insertBefore(error, form.firstChild);
-
-			// Scroll to top to show the error
 			form.scrollIntoView({ behavior: 'smooth', block: 'start' });
 		}
 
-		// Announce error for accessibility
 		if (window.jvbA11y) {
 			const announcement = data.field
 				? `Error in ${data.field}: ${data.message}`
@@ -1385,7 +1466,6 @@
 			window.jvbA11y.announce(announcement);
 		}
 
-		// Trigger custom event
 		form.dispatchEvent(new CustomEvent('jvb-form-error', {
 			detail: data
 		}));
@@ -1448,6 +1528,7 @@
 	**********************************************************************/
 	getForm(element) {
 		let form = element.closest('[data-form-id]');
+		if (!form) return false;
 		let id = form.dataset.formId;
 		if (!id) return false;
 		let config = this.forms.get(id);
@@ -1892,10 +1973,10 @@
 			});
 			this.tagLists.clear();
 		}
-		if(this.charLimits.size > 0) {
+		if (this.charLimits.size > 0) {
 			Array.from(this.charLimits.values()).forEach(charLimit => {
-				charLimit.removeEventListener('input', this.countUpdaters);
-			})
+				charLimit.element.removeEventListener('input', this.countUpdaters);
+			});
 		}
 		this.inputs.clear();
 		this.forms.clear();

--
Gitblit v1.10.0