From 48721c85ebcfa973ee81719d2467ca80e4253dc9 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Fri, 01 May 2026 17:30:03 +0000
Subject: [PATCH] =Edmonton Ink hard test begins! Real testing of the managers and reset routes will commence. So far, just ensuring our classes are all loaded correctly: Site() and its sub-classes Membership, Login, etc. Care should be taken to load conditionally on 'init', as we finish defining most settings by 'plugins_loaded' at priority 5

---
 assets/js/concise/FormController.js | 1000 +++++++++++++++++++++++++++++++++++++++++++++++-----------
 1 files changed, 811 insertions(+), 189 deletions(-)

diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 76ab6c3..70e5b6a 100644
--- a/assets/js/concise/FormController.js
+++ b/assets/js/concise/FormController.js
@@ -25,6 +25,7 @@
 	}
 	init() {
 		this.templates = window.jvbTemplates;
+		this.defineSummaryTemplate();
 		this.initElements();
 		this.initListeners();
 		this.initStore();
@@ -58,7 +59,7 @@
 				field: '.field',					//querySelectorAll
 				label: 'label',
 				success: '.success',
-				error: '.success',
+				error: '.error',
 				message: '.validation-message',
 			},
 			repeater: {
@@ -72,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
 			},
@@ -90,7 +92,7 @@
 				input: 'input[type="number"]'
 			},
 			limits: {
-				hasLimit: '[data-limit]',
+				hasLimit: '[data-maxlength]',
 				limit: '.limit',
 				current: '.current',
 			}
@@ -137,7 +139,7 @@
 		this.store = store.forms;
 
 		this.store.subscribe((event, data)=> {
-			if (event === 'data-loaded') {
+			if (event === 'data-ready') {
 				let stored = this.store.getFiltered();
 
 				let pending = stored.filter(form=> form.src ===  window.location.pathname);
@@ -146,7 +148,7 @@
 				}
 			} else if (event === 'operation-status' && data.status === 'completed') {
 				if (data.config) {
-					this.store.remove(data.config.id);
+					this.store.delete(data.config.id);
 				}
 			}
 		});
@@ -164,15 +166,16 @@
 			notification.className = 'pendingChanges';
 			notification.innerHTML = `
 			<p>We noticed unsaved changes from last time. Would you like to restore them?</p>
-        <button class="restore" data-form-id="${formId}">Restore</button>
-        <button class="discard" data-form-id="${formId}">Discard</button>`;
+        <button class="restore" type="button" data-form-id="${formId}">Restore</button>
+        <button class="discard" type="button" data-form-id="${formId}">Discard</button>`;
 
 			element.insertBefore(notification, form.ui.status.status);
 
 			notification.querySelector('.restore').addEventListener('click', async () => {
 				this.isRestoring = true;
 
-				new this.populate(element, changes);
+				let theChanges = {['fields']: changes};
+				this.populate.populate(element, theChanges);
 				this.a11y.announce('Previous changes restored');
 
 				this.isRestoring = false;
@@ -180,8 +183,8 @@
 			});
 
 			notification.querySelector('.discard').addEventListener('click', async () => {
-				await this.store.remove(formId);
-				this.a11y.announce('Previous changes discared');
+				await this.store.delete(formId);
+				this.a11y.announce('Previous changes discarded');
 				notification.remove();
 			});
 
@@ -240,14 +243,26 @@
 	}
 	performValidation(input) {
 		const field = input.closest('.field');
-		const value = this.getFieldValue(input);
+		const value = this.getFieldCheckedValue(input);
 
 		if (!value && !input.required) {
 			return { isValid: true, message: '' };
 		}
 
-		if (input.required && !value) {
-			return { isValid: false, message: 'This field is required' };
+		if (input.required) {
+			if (input.type === 'checkbox') {
+				if (!input.checked) {
+					return { isValid: false, message: 'This field is required' };
+				}
+			} else if (input.type === 'radio') {
+				const radioGroup = document.querySelectorAll(`input[name="${input.name}"]`);
+				const anyChecked = Array.from(radioGroup).some(r => r.checked);
+				if (!anyChecked) {
+					return { isValid: false, message: 'Please select an option' };
+				}
+			} else if (!value) {
+				return { isValid: false, message: 'This field is required' };
+			}
 		}
 
 		if(input.checkValidity && !input.checkValidity()){
@@ -264,11 +279,11 @@
 		if (Object.hasOwn(field.dataset, 'validate') || input.type) {
 			const validator = this.validators[field.dataset.validate||input.type];
 
-			if (validator.pattern && !validator.pattern.test(value)) {
+			if (validator && validator.pattern && !validator.pattern.test(value)) {
 				return {isValid: false, message: validator.message};
 			}
 
-			if (validator.test) {
+			if (validator && validator.test) {
 				const result = validator.test(value, field);
 				if (result !== true) {
 					return {isValid: false, message: result};
@@ -311,6 +326,26 @@
 		if (e.target.closest('[data-ignore]') || this.isRestoring) return;
 
 		let field = this.getField(e.target);
+
+		// Check if this input lives inside a collection field
+		const collectionField = e.target.closest('[data-field-type="repeater"], [data-field-type="tag-list"]');
+		if (collectionField) {
+			// Dependencies still need checking
+			if (this.dependencies.has(field.dataset.field)) {
+				let dependency = this.dependencies.get(field.dataset.field);
+				dependency.items.forEach(item => {
+					this.checkFieldDependency(item, field.dataset.field);
+				});
+			}
+			const collectionName = collectionField.dataset.field;
+			window.debouncer.schedule(
+				`collection:${collectionName}`,
+				() => this.updateCollectionField(collectionField),
+				150
+			);
+			return;
+		}
+
 		//Dependencies
 		if (this.dependencies.has(field.dataset.field)) {
 			let dependency = this.dependencies.get(field.dataset.field);
@@ -320,8 +355,6 @@
 		}
 
 		let form = this.getForm(e.target);
-		//Autosave
-		if (!form || !form.options.cache) return;
 		this.updateItem(field.dataset.field, this.getFieldValue(e.target), form);
 	}
 
@@ -335,22 +368,34 @@
 		window.debouncer.cancel(`form:${form.id}:validate:${fieldName}`);
 		this.validateField(e.target);
 
-		if (form.options.cache) {
-			this.updateItem(fieldName, this.getFieldValue(e.target), form);
+		// If inside a collection, update the whole collection instead
+		const collectionField = e.target.closest('[data-field-type="repeater"], [data-field-type="tag-list"]');
+		if (collectionField) {
+			this.updateCollectionField(collectionField);
+			return;
 		}
+
+		this.updateItem(fieldName, this.getFieldValue(e.target), form);
 	}
 
 	handleInput(e){
+		if (e.target.closest('[data-ignore]') || this.isRestoring) return;
 		let form = this.getForm(e.target);
-		if (!form || !form.options.cache) return;
+		if (!form) return;
 
 		let field = this.getField(e.target);
 		if (!field) return;
 
-		this.showFormStatus(form, 'pending');
+		const input = e.target;  // Capture reference
+		const fieldName = field.dataset.field;
+
+		// Show pending status regardless of cache
+		this.showFormStatus(form.id, 'pending');
+
+		// Debounce validation
 		window.debouncer.schedule(
-			`form:${form.id}:validate:${field.dataset.field}`,
-			() => this.validateField.bind(this),
+			`form:${form.id}:validate:${fieldName}`,
+			() => this.validateField(input),
 			500
 		);
 	}
@@ -361,20 +406,28 @@
 
 		if (this.subscribers.size > 0) {
 			e.preventDefault();
-			const storedData = await this.store.get(form.id);
 
-			this.notify('form-submit', {
-				config: form,
-				data: storedData?.changes || {}
-			});
+			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
+				});
+			} else {
+				this.notify('form-submit', {
+					config: form,
+					data: this.changes.get(form.id)?.changes??{},
+				});
+			}
+
 		}
 
 		if (form.options.showSummary) {
 			const storedData = await this.store.get(form.id);
-			this.showSummary(form.id, {
-				config: form,
-				data: storedData?.changes || {}
-			});
+			this.showSummary({config: form, changes: storedData?.changes});
 		}
 	}
 
@@ -396,24 +449,55 @@
 		let changes = this.changes.get(form.id);
 		changes.changes[name] = value;
 		this.changes.set(form.id, changes);
-		this.scheduleBackup();
+		if (form.options.cache) {
+			this.scheduleBackup();
+		}
 	}
 
-	scheduleBackup()  {
+	scheduleBackup() {
 		window.debouncer.schedule(
 			`form_changes`,
 			async () => {
 				if (this.changes.size > 0) {
-					await this.store.saveMany(this.changes);
-					for(let formId of this.changes.keys()) {
-						this.showFormStatus(formId, 'autosaved');
-					}
-					this.changes.clear();
+					await this.backup();
 				}
 			},
 			2000
 		);
 	}
+	cancelBackup() {
+		window.debouncer.cancel('form_changes');
+	}
+	async backup() {
+		// Merge with existing stored data
+		const toSave = new Map();
+
+		for (let [formId, newData] of this.changes.entries()) {
+			const stored = await this.store.get(formId);
+
+			if (stored) {
+				// Merge changes
+				toSave.set(formId, {
+					...stored,
+					...newData,
+					changes: {
+						...stored.changes,
+						...newData.changes
+					},
+					timestamp: Date.now()
+				});
+			} else {
+				toSave.set(formId, newData);
+			}
+		}
+
+		await this.store.saveMany(toSave);
+
+		for (let formId of this.changes.keys()) {
+			this.showFormStatus(formId, 'autosaved');
+		}
+		this.changes.clear();
+	}
 
 	saveCache(formId) {
 		if (!this.changes.has(formId)) return;
@@ -445,20 +529,18 @@
 			id: formId,
 			status: '',
 			options: {
-				autoUpload: false,
+				autoUpload: options.autoUpload??false,
+				imageMeta: options.imageMeta??true,
 				delay: options.delay??1500,
 				endpoint: options.save??form.dataset.save??'',
-				formStatus: options.showStatus??true,
-				showSummary: false,
+				showStatus: options.showStatus??true,
+				showSummary: options.showSummary??false,
 				cache: options.cache??true,
+				ignore: options.ignore??[]
 			},
 			ui: window.uiFromSelectors(this.selectors.forms, form)
 		};
 
-		if (config.showSummary && !this.summaryTemplate) {
-			this.defineSummaryTemplate();
-		}
-
 		this.initializeFields(form, config);
 		this.forms.set(formId, config);
 
@@ -552,8 +634,11 @@
 								this.removeQuantityListeners(item.element);
 								break;
 						}
+
+						if (check.has(item.id)) {
+							check.delete(item.id);
+						}
 					});
-					check.delete(item.id);
 				}
 			}
 
@@ -575,12 +660,12 @@
 						p: 'p',
 					},
 					setup({ el, refs, manyRefs, data }) {
-						const skipFields = ['sendAll', ...form.ignore];
+						const skipFields = ['sendAll', ...data.config.options.ignore??[]];
 
 						for (let [key, value] of Object.entries(data.changes)) {
-							if (skipFields.includes(key) || this.isEmptyValue(value)) continue;
+							if (skipFields.includes(key) || form.isEmptyValue(value)) continue;
 
-							let input = Array.from(this.inputs.values())
+							let input = Array.from(form.inputs.values())
 								.find(temp => temp.field?.dataset.field === key);
 							if (!input) continue;
 
@@ -588,15 +673,21 @@
 							let title = entry.querySelector('h3');
 							let p = entry.querySelector('p');
 
-							title.textContent = input.label.textContent;
+							// Get field label - prioritize legend for fieldsets, then label
+							const legend = input.field?.querySelector('legend');
+							title.textContent = legend
+								? legend.textContent.replace('*', '').trim()
+								: input.ui.label?.textContent.replace('*', '').trim();
 
-							if (typeof value === 'string') {
-								p.textContent = value;
-							} else if (Array.isArray(value)) {
-								//Repeater or Tag Item
-							} else if (typeof value === 'object') {
-								//Location item
-								p.textContent = `${value.address}`;
+
+							const formattedValue = form.formatValueForSummary(value, input);
+
+							if (formattedValue instanceof HTMLElement) {
+								// If it's an HTML element (repeater, tag-list, etc.), replace <p>
+								p.replaceWith(formattedValue);
+							} else {
+								// If it's a string, set text content
+								p.textContent = formattedValue;
 							}
 
 							el.append(entry);
@@ -606,6 +697,7 @@
 							uploads.forEach(upload => {
 								let label = upload.querySelector('h2')?.textContent??'Upload:';
 								let imgs = upload.querySelectorAll('.item-grid.preview img');
+								let field = refs.result.cloneNode(true);
 								if (imgs) {
 									let entry = refs.result.cloneNode(true);
 									let title = field.querySelector('h3');
@@ -628,6 +720,8 @@
 				}
 			);
 		}
+
+
 		initializeFields(container, config = null) {
 			const fieldHandlers = {
 				'[data-editor]': () => this.checkForQuill(container,config),
@@ -636,7 +730,7 @@
 				'.field.tag-list': () => this.checkForTagLists(container),
 				'[data-depends-on]': () => this.checkForConditionalFields(container),
 				'[data-limit]': () => this.checkForCharacterLimits(container),
-				'[data-uploader]': () => this.checkForImageUploads(container, config),
+				'[data-uploader],[data-upload-field]': () => this.checkForImageUploads(container, config),
 				'nav.tabs': () => this.checkForTabs(container, config),
 				'[data-type="selector"]': () => this.checkForSelectors(container)
 			};
@@ -647,7 +741,8 @@
 				}
 			}
 
-			let inputs = Array.from(container.querySelectorAll(this.inputSelectors));
+			let inputs = Array.from(container.querySelectorAll(this.inputSelectors))
+				.filter(input => !input.closest('.ql-clipboard'));
 			inputs.map(input => {
 				this.getItem(input, config?.id);
 			});
@@ -692,14 +787,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;
@@ -708,23 +803,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 => {
@@ -735,9 +831,10 @@
 						element: repeater,
 						field: this.getField(repeater),
 						sortable: false,
+						rows: []
 					};
 
-					if (!config.ui.addButton) return;
+					if (!config.ui.add) return;
 
 					let template = repeater.querySelector('template');
 					this.templates.define(
@@ -749,8 +846,9 @@
 							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, false, true);
 								});
 							}
 						},
@@ -765,6 +863,7 @@
 							}
 						});
 					}
+
 					repeater.dataset.repeaterId = config.id;
 					this.addRepeaterListeners(repeater);
 					this.repeaters.set(config.id, config);
@@ -781,11 +880,24 @@
 					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;
+					let config = this.repeaters.get(repeater.dataset.repeaterId);
+
+					let row = this.templates.create(repeater.dataset.repeaterId, data);
+					config.rows.push({
+						element: row,
+						fields: Array.from(row.querySelectorAll('[data-field]'))
+					});
+					this.repeaters.set(config.id, config);
+					config.ui.items.append(row);
+
+					let form = this.getForm(repeater);
+					this.initializeFields(repeater, form);
 					this.a11y.announce('Row added');
 				}
 				removeRepeaterRow(row) {
@@ -806,6 +918,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(
@@ -821,7 +934,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, false, true)
 								});
 
 								if (refs.label) {
@@ -830,7 +944,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);
 				});
@@ -838,91 +953,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]');
@@ -973,7 +1121,7 @@
 					const controlField = this.dependencies.get(controlFieldName);
 					if (!controlField) return;
 
-					const controlValue = this.getFieldValue(controlField.element);
+					const controlValue = this.getFieldCheckedValue(controlField.element);
 					const shouldShow = this.evaluateCondition(
 						controlValue,
 						dependentField.requiredValue,
@@ -1015,25 +1163,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 = this.getFieldInput(field);
+			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});
 				}
@@ -1055,7 +1210,7 @@
 					}
 				}
 			checkForImageUploads(form, config) {
-				window.jvbUploads.scanFields(form, config.autoUpload);
+				window.jvbUploads.scanFields(form, config.options.autoUpload, config.options.imageMeta);
 			}
 
 			checkForTabs(form, config) {
@@ -1117,19 +1272,53 @@
 	 * @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,
+					false,
+					true
+				);
 			});
 		});
 
-		//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);
+		this.updateItem(field.dataset.field, value, form);
 	}
 	/**********************************************************************
 	 VALIDATION
@@ -1194,6 +1383,129 @@
 		}
 	}
 
+	handleFormSuccess(form, data) {
+		// Clear previous errors
+		form.querySelectorAll('.error-message').forEach(el => el.remove());
+		form.querySelectorAll('.field-error').forEach(el =>
+			el.classList.remove('field-error')
+		);
+
+		// Add success class to form
+		form.classList.add('form-success');
+
+		// Show success message if provided
+		if (data.message) {
+			const success = document.createElement('div');
+			success.className = 'form-success-message success-message';
+			success.textContent = data.message;
+			form.insertBefore(success, form.firstChild);
+
+			const icon = window.getIcon?.('check-circle');
+			if (icon) {
+				icon.classList.add('success-icon');
+				success.prepend(icon);
+			}
+		}
+
+		// If there's a title/description (for registration success)
+		if (data.title || data.description) {
+			const successBox = document.createElement('div');
+			successBox.className = 'success-box';
+
+			if (data.title) {
+				const title = document.createElement('h3');
+				title.textContent = data.title;
+				successBox.appendChild(title);
+			}
+
+			if (data.description) {
+				const descriptions = Array.isArray(data.description)
+					? data.description
+					: [data.description];
+
+				descriptions.forEach(desc => {
+					const p = document.createElement('p');
+					p.textContent = desc;
+					successBox.appendChild(p);
+				});
+			}
+
+			form.insertBefore(successBox, form.firstChild);
+		}
+
+		//  DELETE CACHED FORM DATA ON SUCCESS
+		if (form.dataset.formId) {
+			this.store.delete(form.dataset.formId).catch(err => {
+				console.warn('Failed to clear form cache:', err);
+			});
+
+			// Clear form config dirty state
+			const formConfig = this.forms.get(form.dataset.formId);
+			if (formConfig) {
+				formConfig.isDirty = false;
+				formConfig.lastSaved = Date.now();
+				formConfig.data = {}; // Clear cached data
+			}
+		}
+
+		// Announce success for accessibility
+		if (window.jvbA11y) {
+			window.jvbA11y.announce(data.message || 'Form submitted successfully');
+		}
+	}
+
+	handleFormError(form, data) {
+		// Clear all previous errors
+		form.querySelectorAll('.error-message').forEach(el => el.remove());
+		form.querySelectorAll('.field-error, .has-error').forEach(el => {
+			el.classList.remove('field-error', 'has-error');
+		});
+
+		// Clear validation states using existing method
+		form.querySelectorAll('.field').forEach(fieldWrapper => {
+			this.clearValidation(fieldWrapper);
+		});
+
+		// Handle field-specific errors
+		if (data.field) {
+			const fieldWrapper = form.querySelector(`[data-field="${data.field}"]`);
+			if (fieldWrapper) {
+				this.showError(fieldWrapper, data.message);
+
+				fieldWrapper.scrollIntoView({ behavior: 'smooth', block: 'center' });
+
+				const input = fieldWrapper.querySelector('input, textarea, select');
+				if (input) {
+					input.focus();
+				}
+			}
+		} else {
+			const error = document.createElement('div');
+			error.className = 'form-error error-message';
+			error.textContent = data.message;
+
+			const icon = window.getIcon?.('close-circle');
+			if (icon) {
+				icon.classList.add('error-icon');
+				error.prepend(icon);
+			}
+
+			form.insertBefore(error, form.firstChild);
+			form.scrollIntoView({ behavior: 'smooth', block: 'start' });
+		}
+
+		if (window.jvbA11y) {
+			const announcement = data.field
+				? `Error in ${data.field}: ${data.message}`
+				: `Form error: ${data.message}`;
+			window.jvbA11y.announce(announcement);
+		}
+
+		form.dispatchEvent(new CustomEvent('jvb-form-error', {
+			detail: data
+		}));
+	}
+
 	/**********************************************************************
 	STATUS
 	 **********************************************************************/
@@ -1202,6 +1514,8 @@
 		if (!form || !form.options.showStatus || !form.ui?.status?.status) return;
 		if (form.status === status) return;
 
+
+		form.status = status;
 		form.ui.status.status.hidden = false;
 		form.ui.status.status.classList.toggle('loading', ['uploading', 'saving'].includes(status));
 
@@ -1240,13 +1554,16 @@
 	 SUMMARY
 	**********************************************************************/
 	showSummary(data) {
-		this.templates.create('formSummary', data);
+		let summary = this.templates.create('formSummary', data);
+		data.config.element.after(summary);
+		window.fade(data.config.element, false);
 	}
 	/**********************************************************************
 	 UTILITY
 	**********************************************************************/
 	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);
@@ -1283,35 +1600,91 @@
 
 			case 'selector':
 			case 'upload':
+			case 'gallery':
+			case 'image':
 				return this.getHiddenInputValue(element, conf, fieldName);
 
 			case 'true-false':
-				return element.value === '1'||element.value === 'on'||element.value ==='true';
-
+			case 'toggle-text':
+				return element.checked;
+			case 'checkbox':
+				// Handle multi-checkbox (name ends with [])
+				if (element.name.endsWith('[]')) {
+					return this.getCheckboxGroupValue(element, conf);
+				}
+				return element.checked ? element.value : '';
 			default:
 				return element.value;
 		}
 	}
+
+	/**
+	 * Get all checked values for a checkbox group
+	 */
+	getCheckboxGroupValue(element, conf) {
+		if (!conf.checkboxGroup) {
+			conf.checkboxGroup = conf.field?.querySelectorAll(`input[type="checkbox"][name="${element.name}"]`);
+			this.saveItem(conf);
+		}
+
+		return Array.from(conf.checkboxGroup)
+			.filter(cb => cb.checked)
+			.map(cb => cb.value);
+	}
+	/**
+	 * Get the actual user-facing value (for validation and submission)
+	 */
+	getFieldCheckedValue(element) {
+		// Handle checkboxes and radios based on checked state
+		if (element.type === 'checkbox') {
+			const type = this.getFieldType(element);
+			if (type === 'true-false') {
+				return element.checked;
+			}
+			return element.checked ? element.value : '';
+		}
+
+		if (element.type === 'radio') {
+			const radioGroup = document.querySelectorAll(`input[name="${element.name}"]`);
+			const checked = Array.from(radioGroup).find(r => r.checked);
+			return checked ? checked.value : '';
+		}
+
+		// For everything else, use existing logic
+		return this.getFieldValue(element);
+	}
+
 	isEmptyValue(value) {
 		if (value === null || value === undefined || value === '') return true;
 		if (Array.isArray(value) && value.length === 0) return true;
 		return typeof value === 'object' && Object.keys(value).length === 0;
 	}
 		getRepeaterValue(element, conf) {
-			if (!conf.container) {
-				conf.container = conf.field?.querySelector('.repeater-items');
-				this.saveItem(conf);
-			}
+			const items = element.querySelector('.repeater-items');
+			if (!items) return [];
+			let ignore = ['image_data','image-title','image-caption','image-description','image-alt-text']
 			let value = [];
-			Array.from(conf.container.children).forEach(row => {
+			Array.from(items.children).forEach(row => {
 				let rowData = {};
 				row.querySelectorAll('[data-field]').forEach(field => {
-					rowData[field.dataset.field] = this.getFieldValue(field);
+					if (!ignore.includes(field.dataset.field)) {
+						const input = this.getFieldInput(field);
+						if (input) {
+							rowData[field.dataset.field] = this.getFieldValue(input);
+						}
+					}
 				});
 				value.push(rowData);
 			});
 			return value;
 		}
+	getFieldInput(field) {
+		// For quill fields, target the specific editor textarea
+		const quillTextarea = field.querySelector('textarea[data-editor]');
+		if (quillTextarea) return quillTextarea;
+
+		return field.querySelector(this.inputSelectors);
+	}
 		getTagListValue(element, conf) {
 			if (!conf.container) {
 				conf.container = conf.field?.querySelector('.tag-items');
@@ -1339,14 +1712,246 @@
 			});
 			return value;
 		}
-		getHiddenInputValue(element, conf, fieldName) {
-			if (!conf.value) {
-				conf.value = conf.field?.querySelector(`input[type=hidden][name="${fieldName}"]`);
-				this.saveItem(conf);
-			}
-			return conf.value.value;
+	getHiddenInputValue(element, conf, fieldName) {
+		if (!conf.value) {
+			conf.value = conf.field?.querySelector(`input[type=hidden][name="${fieldName}"]`)
+				|| conf.field?.querySelector(`input[type=hidden]`);
+			this.saveItem(conf);
+		}
+		return conf.value?.value ?? '';
+	}
+
+	/**
+	 * Format field value for display in summary
+	 * @param {*} value - The field value
+	 * @param {Object} input - The input config
+	 * @returns {HTMLElement|string} - Formatted display element or string
+	 */
+	formatValueForSummary(value, input) {
+		const fieldType = this.getFieldType(input.element);
+
+		// Handle empty values
+		if (this.isEmptyValue(value)) {
+			return '';
 		}
 
+		// Handle different field types
+		switch (fieldType) {
+			case 'repeater':
+				return this.formatRepeaterForSummary(value, input);
+
+			case 'tag-list':
+				return this.formatTagListForSummary(value, input);
+
+			case 'location':
+				return this.formatLocationForSummary(value);
+
+			case 'true-false':
+				return value ? 'Yes' : 'No';
+
+			case 'checkbox':
+				// Handle multi-checkbox arrays
+				if (Array.isArray(value)) {
+					return this.formatCheckboxGroupForSummary(value, input);
+				}
+				// Single checkbox - get display label
+				return this.getDisplayLabel(input, value);
+
+			case 'selector':
+			case 'upload':
+			case 'image':	//legacy, shouldn't be needed
+			case 'gallery':	//legacy, shouldn't be needed
+				// These might need special handling depending on your needs
+				return this.formatHiddenFieldForSummary(value, input, fieldType);
+
+			default:
+				// For radio/checkbox, get the display label
+				if (typeof value === 'string') {
+					return this.getDisplayLabel(input, value);
+				}
+				// For textarea or any multi-line text, convert line breaks
+				if (typeof value === 'string' && value.includes('\n')) {
+					return this.convertLineBreaks(value);
+				}
+				return value;
+		}
+	}
+
+	/**
+	 * Format checkbox group values with labels
+	 */
+	formatCheckboxGroupForSummary(values, input) {
+		const labels = values.map(value => this.getDisplayLabel(input, value));
+		return labels.join(', ');
+	}
+
+	/**
+	 * Convert \n line breaks to HTML
+	 */
+	convertLineBreaks(text) {
+		const container = document.createElement('span');
+		container.innerHTML = text.split('\n').join('<br>');
+		return container;
+	}
+
+	/**
+	 * Format repeater data as a list
+	 */
+	formatRepeaterForSummary(rows, input) {
+		const container = document.createElement('div');
+		container.className = 'summary-repeater';
+
+		rows.forEach((row, index) => {
+			const rowDiv = document.createElement('div');
+			rowDiv.className = 'summary-repeater-row';
+
+			const rowTitle = document.createElement('strong');
+			rowTitle.textContent = `Entry ${index + 1}:`;
+			rowDiv.appendChild(rowTitle);
+
+			const fieldsList = document.createElement('ul');
+			fieldsList.className = 'summary-repeater-fields';
+
+			for (const [fieldName, fieldValue] of Object.entries(row)) {
+				if (this.isEmptyValue(fieldValue)) continue;
+
+				const li = document.createElement('li');
+
+				// Try to find the label for this subfield
+				const subFieldElement = input.field?.querySelector(`[data-field="${fieldName}"]`);
+				const label = subFieldElement?.closest('.field')?.querySelector('label')?.textContent.replace('*', '').trim() || fieldName;
+
+				li.innerHTML = `<span class="field-label">${label}:</span> <span class="field-value">${fieldValue}</span>`;
+				fieldsList.appendChild(li);
+			}
+
+			rowDiv.appendChild(fieldsList);
+			container.appendChild(rowDiv);
+		});
+
+		return container;
+	}
+
+	/**
+	 * Format tag-list data
+	 */
+	formatTagListForSummary(tags, input) {
+		const container = document.createElement('div');
+		container.className = 'summary-taglist';
+
+		const tagsList = document.createElement('ul');
+		tagsList.className = 'summary-tags';
+
+		tags.forEach(tag => {
+			const li = document.createElement('li');
+			li.className = 'summary-tag';
+
+			// Get the primary display value (first non-empty field)
+			const displayValue = Object.values(tag).find(v => !this.isEmptyValue(v)) || '';
+
+			// If there are multiple fields, show them all
+			const fields = Object.entries(tag).filter(([k, v]) => !this.isEmptyValue(v));
+			if (fields.length > 1) {
+				li.textContent = fields.map(([k, v]) => v).join(', ');
+			} else {
+				li.textContent = displayValue;
+			}
+
+			tagsList.appendChild(li);
+		});
+
+		container.appendChild(tagsList);
+		return container;
+	}
+
+	/**
+	 * Format location data
+	 */
+	formatLocationForSummary(location) {
+		const parts = [];
+
+		if (location.street) parts.push(location.street);
+		if (location.city) parts.push(location.city);
+		if (location.province) parts.push(location.province);
+		if (location.postal_code) parts.push(location.postal_code);
+		if (location.country) parts.push(location.country);
+
+		return parts.length > 0 ? parts.join(', ') : location.address || '';
+	}
+
+	/**
+	 * Format hidden field types (upload, selector)
+	 */
+	formatHiddenFieldForSummary(value, input, fieldType) {
+		if (['upload', 'gallery', 'image'].includes(fieldType)) {
+			// Get upload preview images if available
+			const uploadField = input.field?.querySelector('[data-upload-field]');
+			if (uploadField) {
+				const previews = uploadField.querySelectorAll('.item-grid.preview img');
+				if (previews.length > 0) {
+					const container = document.createElement('div');
+					container.className = 'summary-uploads';
+					previews.forEach(img => {
+						const clone = img.cloneNode(true);
+						clone.style.maxWidth = '100px';
+						clone.style.maxHeight = '100px';
+						container.appendChild(clone);
+					});
+					return container;
+				}
+			}
+			return `${value.split(',').length} file(s) uploaded`;
+		}
+
+		if (fieldType === 'selector') {
+			// Could enhance this to show selected item names if available
+			return value;
+		}
+
+		return value;
+	}
+
+	/**
+	 * Get the display label for an input value (especially for radio/checkbox)
+	 * @param {Object} input - The input config from this.inputs
+	 * @param {*} value - The field value
+	 * @returns {string} - The display label or original value
+	 */
+	getDisplayLabel(input, value) {
+		if (!input.element) return value;
+
+		const inputType = input.element.type;
+
+		// Handle radio buttons
+		if (inputType === 'radio') {
+			const radioGroup = input.field.querySelectorAll(`input[type="radio"][name="${input.element.name}"]`);
+			const selectedRadio = Array.from(radioGroup).find(radio => radio.value === value);
+			if (selectedRadio) {
+				const label = selectedRadio.closest('label') ||
+					input.field.querySelector(`label[for="${selectedRadio.id}"]`);
+				if (label) {
+					return label.textContent.replace('*', '').trim();
+				}
+			}
+		}
+
+		// Handle checkboxes (including groups)
+		if (inputType === 'checkbox' && this.getFieldType(input.element) !== 'true-false') {
+			// Find checkbox with this value in the field
+			const checkbox = input.field.querySelector(`input[type="checkbox"][value="${value}"]`);
+			if (checkbox) {
+				const label = checkbox.closest('label') ||
+					input.field.querySelector(`label[for="${checkbox.id}"]`);
+				if (label) {
+					// Get just the span content to avoid getting nested elements
+					const span = label.querySelector('span');
+					return span ? span.textContent.trim() : label.textContent.replace('*', '').trim();
+				}
+			}
+		}
+
+		return value;
+	}
 	getItem(element, formId = null) {
 		const hasID = Object.hasOwn(element.dataset, 'ref');
 		let id = (hasID) ? element.dataset.ref : window.generateID('input');
@@ -1375,6 +1980,23 @@
 		this.inputs.set(config.id, config);
 	}
 	/**********************************************************************
+	 Subscription
+	**********************************************************************/
+	subscribe(callback) {
+		this.subscribers.add(callback);
+		return () => this.subscribers.delete(callback);
+	}
+
+	notify(event, data) {
+		this.subscribers.forEach(cb => {
+			try {
+				cb(event, data);
+			} catch (e) {
+				console.error('HandleSelection subscriber error:', e);
+			}
+		});
+	}
+	/**********************************************************************
 	 Cleanup
 	**********************************************************************/
 	destroy() {
@@ -1403,10 +2025,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