From fff721dd185f5b97f7ae7a6e64189e55887ff590 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sun, 05 Jul 2026 18:36:57 +0000
Subject: [PATCH] =Cleaning up the Square integration (still a bit more to do yet). Also majorly overhauled /rest/ files to ignore a rest request 'user' paramater, and rely on get_current_user_id() instead.

---
 assets/js/concise/FormController.js |  395 ++++++++++++++++++++++++++++++++++++++++++++++++--------
 1 files changed, 336 insertions(+), 59 deletions(-)

diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 19e4a81..ccbea31 100644
--- a/assets/js/concise/FormController.js
+++ b/assets/js/concise/FormController.js
@@ -51,7 +51,7 @@
 		this.selectors = {
 			tabs: {
 				nav: 'nav.tabs',
-				sections: '.tab.content',			//querySelectorAll
+				sections: '.tab-content',			//querySelectorAll
 				progress: {
 					progress: '.progress',
 					fill: '.progress .fill',
@@ -84,6 +84,8 @@
 			},
 			repeater: {
 				repeater: '.repeater',				//querySelectorAll
+				row: '.repeater-row',
+				handle: '.drag-handle',
 				header: '.repeater-row-header',
 				remove: '.remove-row',
 				add: '.add-repeater-row',
@@ -210,7 +212,10 @@
 					let field = key.replace('_tempUpload', '');
 
 					if (Object.hasOwn(form.ui.uploads, field)) {
-						uploads.push(value);
+						uploads = [
+							... uploads,
+							... value
+						];
 					}
 				}
 			}
@@ -353,6 +358,28 @@
 				case 'dismiss-restore':
 					form.ui.status.status.hidden = true;
 					break;
+				case 'next-step':
+					e.preventDefault();
+					form.tabs.ui.buttons.forEach((btn, index) => {
+						if (btn.dataset.tab === form.tabs.activeTab) {
+							let next = form.tabs.ui.buttons[index + 1]??false;
+							if (next) {
+								window.jvbTabs.switchTab(next.dataset.tab, window.jvbTabs.getConfig(next));
+							}
+						}
+					});
+					break;
+				case 'prev-step':
+					e.preventDefault();
+					form.tabs.ui.buttons.forEach((btn, index) => {
+						if (btn.dataset.tab === form.tabs.activeTab) {
+							let prev = form.tabs.ui.buttons[index - 1]??false;
+							if (prev) {
+								window.jvbTabs.switchTab(prev.dataset.tab, window.jvbTabs.getConfig(prev));
+							}
+						}
+					});
+					break;
 			}
 		}
 	}
@@ -368,11 +395,12 @@
 			// Dependencies still need checking
 			if (this.dependencies.has(field.dataset.field)) {
 				let dependency = this.dependencies.get(field.dataset.field);
-				dependency.items.forEach(item => {
+				dependency.forEach(item => {
 					this.checkFieldDependency(item, field.dataset.field);
 				});
 			}
 			const collectionName = collectionField.dataset.field;
+			this.maybeUpdateCollectionDisplay(field);
 			window.debouncer.schedule(
 				`collection:${collectionName}`,
 				() => this.updateCollectionField(collectionField),
@@ -384,7 +412,7 @@
 		//Dependencies
 		if (this.dependencies.has(field.dataset.field)) {
 			let dependency = this.dependencies.get(field.dataset.field);
-			dependency.items.forEach(item => {
+			dependency.forEach(item => {
 				this.checkFieldDependency(item, field.dataset.field);
 			});
 		}
@@ -454,9 +482,14 @@
 			} else {
 				this.notify('form-submit', {
 					config: form,
-					data: this.changes.get(form.id)?.changes??{},
+					data: this.changes.get(form.id)?.changes ?? {},
 				});
 			}
+		}
+		if (form.options.endpoint && !form.options.handled) {
+			e.preventDefault();
+			console.log('Submitting form...', this.changes.get(form.id)?.changes ?? {});
+			await this.handleServerSave(form, this.changes.get(form.id)?.changes ?? {});
 
 		}
 
@@ -483,12 +516,88 @@
 			});
 		}
 		let changes = this.changes.get(form.id);
-		changes.changes[name] = value;
+		//If it is temporary uploads, we need to store them all
+		if (name.includes('_tempUpload')) {
+			if (!Object.hasOwn(changes.changes, name)) {
+				changes.changes[name] = [];
+			}
+			changes.changes[name].push(value);
+		} else {
+			changes.changes[name] = value;
+		}
+
+		if (form.tabs) {
+			this.updateStepProgress(form);
+		}
+
 		this.changes.set(form.id, changes);
 		if (form.options.cache) {
 			this.scheduleBackup();
 		}
+
+		if (form.options.endpoint && !form.options.handled) {
+			this.scheduleServerSave(form, changes);
+		}
 	}
+		updateStepProgress(form) {
+			console.log('First check in updateStepProgress');
+			if (!form.tabs) return;
+			let currentStep = this.getCurrentStep(form);
+			console.log('Current step: ', currentStep);
+			if (!currentStep) return;
+
+			//The progress root will be 1 less than whatever the current step is
+			currentStep = currentStep - 1;
+
+			let section = this.getCurrentSection(form);
+			console.log('Current section: ', section);
+			if (!section) return;
+
+			let total = 0;
+			let filled = 0;
+			section.childNodes.forEach(f => {
+				console.log('Child node: ', f);
+				if (f.classList.contains('field')) {
+					total++;
+					let value = this.getFieldValue(f.querySelector(this.inputSelectors));
+					console.log('Value', value);
+					if (value.length > 0) {
+						filled++;
+					}
+				}
+			});
+			//Shouldn't happen, but simplifies things if it does
+			if (total === 0) return;
+			let current = currentStep + (filled / total);
+
+			let totalSteps = form.ui.tabs.sections.length;
+			window.showProgress(
+				form.ui.tabs.progress,
+				current,
+				totalSteps,
+				`Step ${currentStep +1} of ${totalSteps}`
+			);
+		}
+			getCurrentStep(form) {
+				let step = null;
+				form.tabs.ui.buttons.forEach(btn => {
+					if (btn.dataset.tab === form.tabs.activeTab) {
+						step = btn.dataset.step;
+					}
+				});
+				return step;
+			}
+
+			getCurrentSection(form) {
+				console.log('Getting current section: ', form.tabs.ui.sections);
+				let section = null;
+				form.tabs.ui.sections.forEach(s => {
+					if (s.dataset.tab === form.tabs.activeTab) {
+						section = s;
+					}
+				});
+				return section;
+			}
 
 	scheduleBackup() {
 		window.debouncer.schedule(
@@ -543,6 +652,89 @@
 		this.changes.delete(formId);
 	}
 
+	scheduleServerSave(form, changes){
+
+		window.debouncer.schedule(
+			`form_${form.id}_server_save`,
+			async () => {
+				await this.handleServerSave(form, changes);
+			},
+			1500
+		);
+	}
+	cancelServerSave(formId) {
+		window.debouncer.cancel(`form_${formId}_server_save`);
+	}
+	async handleServerSave(form, changes) {
+		this.cancelServerSave(form.id);
+
+		if (form.useQueue && window.jvbQueue) {
+			changes.user = window.auth.getUser();
+			window.jvbQueue.addToQueue({
+				endpoint: form.options.endpoint,
+				data: changes,
+				headers: form.options.headers??{},
+			});
+		} else {
+			try {
+				let response = await window.auth.fetch(jvbSettings.api+form.options.endpoint, {
+					body: changes,
+					headers: form.options.headers??{},
+					method: 'POST',
+				});
+
+				if (!response.ok) {
+					let errorDetails;
+					try {
+						// Attempt to parse the server's error payload as JSON
+						errorDetails = await response.json();
+					} catch {
+						// Fallback if the error response isn't JSON (e.g., HTML error page)
+						errorDetails = { message: response.statusText || 'Unknown HTTP error' };
+					}
+					this.notify('form-error', {
+						config: form,
+						data: errorDetails
+					});
+					return;
+				}
+				let result = await response.json();
+				this.notify('form-success', {
+					config: form,
+					result: result
+				});
+			} catch (error) {
+				if (error.status) {
+					this.notify('form-error', {
+						config: form,
+						data: error
+					});
+					// This is an HTTP error thrown manually (e.g., 404, 500)
+				} else if (error instanceof SyntaxError) {
+					this.notify('form-error', {
+						config: form,
+						data: {
+							status: 'error',
+							success: false,
+							message: 'Something went wrong'
+						}
+					});
+					// This happens if response.json() fails on a successful 200 OK response
+					console.error('Data Error: Server returned invalid JSON format.');
+				} else {
+					this.notify('form-error', {
+						config: form,
+						data: {
+							status: 'network_error',
+							message: 'Network error: could not connect to the server'
+						}
+					});
+				}
+			}
+
+		}
+	}
+
 
 	/**
 	 * Register a form for handling
@@ -550,12 +742,29 @@
 	 * @param {object} options
 	 */
 	registerForm(form, options) {
+		options = {
+			autoUpload: false,
+			imageMeta: true,
+			delay: 1500,
+			useQueue: true,
+			endpoint: Object.hasOwn(form.dataset, 'save') ? form.dataset.save: '',
+			showStatus: true,
+			showSummary: false,
+			handled: Object.hasOwn(form.dataset, 'handled'),
+			cache: true,
+			ignore: [],
+			... options
+		};
+
 		//Bail if form already registered
 		if (Object.hasOwn(form.dataset, 'formId') && this.forms.has(form.dataset.formId)) return;
 
 		if (!Object.hasOwn(form.dataset, 'formId')) {
 			form.dataset.formId = window.generateID('form_');
 		}
+		if (Object.hasOwn(form.dataset, 'action')) {
+			options.headers = window.auth.getHeader(form.dataset.action);
+		}
 		const formId = form.dataset.formId;
 
 		this.addFormListeners(form);
@@ -564,22 +773,17 @@
 			element: form,
 			id: formId,
 			status: '',
-			options: {
-				autoUpload: options.autoUpload??false,
-				imageMeta: options.imageMeta??true,
-				delay: options.delay??1500,
-				endpoint: options.save??form.dataset.save??'',
-				showStatus: options.showStatus??true,
-				showSummary: options.showSummary??false,
-				cache: options.cache??true,
-				ignore: options.ignore??[]
-			},
+			options: options,
 			ui: window.uiFromSelectors(this.selectors.forms, form)
 		};
 
+		config.ui.fields = {};
+		form.querySelectorAll('[data-field]').forEach((field) => {
+			config.ui.fields[field.dataset.field] = field;
+		});
+
 		this.initializeFields(form, config);
 		this.forms.set(formId, config);
-
 		return config;
 	}
 	clearForm(formId) {
@@ -603,10 +807,10 @@
 		}
 		// Clean up dependencies for this form
 		this.dependencies.forEach((dependency, fieldName) => {
-			dependency.items = dependency.items.filter(item => item.form !== formId);
+			dependency = dependency.filter(item => item.form !== formId);
 
 			// Remove the dependency entry entirely if no items left
-			if (dependency.items.length === 0) {
+			if (dependency.length === 0) {
 				this.dependencies.delete(fieldName);
 			}
 		});
@@ -696,7 +900,7 @@
 					p: 'p',
 				},
 				setup({ el, refs, manyRefs, data }) {
-					const skipFields = ['sendAll', ...data.config.options.ignore??[]];
+					const skipFields = ['sendAll', ...data?.config?.options?.ignore??[]];
 
 					for (let [key, value] of Object.entries(data.changes)) {
 						if (skipFields.includes(key) || form.isEmptyValue(value)) continue;
@@ -750,8 +954,6 @@
 					}
 
 					refs.result?.remove();
-					data.config.element.after(el);
-					window.fade(data.config.element, false);
 				}
 			}
 		);
@@ -856,9 +1058,7 @@
 		}
 	}
 	checkForRepeaters(form) {
-
 		if (!form.querySelector(this.selectors.repeater.repeater)) return;
-
 		form.querySelectorAll(this.selectors.repeater.repeater).forEach(repeater => {
 			let config = {
 				id: repeater.querySelector('template').className??window.generateID('repeater'),
@@ -891,11 +1091,15 @@
 			);
 
 			if (window.Sortable) {
-				config.sortable = new Sortable(repeater, {
-					handle: this.selectors.repeater.header,
+				config.sortable = new Sortable(config.ui.items, {
 					animation: 150,
+					draggable: this.selectors.repeater.row,
+					handle: this.selectors.repeater.handle,
+					ghostClass: 'repeater-row-ghost',
+					chosenClass: 'repeater-row-chosen',
+					dragClass: 'dragging',
 					onEnd: () => {
-						this.reindexList(repeater);
+						this.reindexList(config.ui.items);
 					}
 				});
 			}
@@ -934,12 +1138,17 @@
 
 		let form = this.getForm(repeater);
 		this.initializeFields(repeater, form);
+		this.reindexList(config.ui.items);
 		this.a11y.announce('Row added');
 	}
 	removeRepeaterRow(row) {
 		let repeater = row.closest('[data-repeater-id]');
 		row.remove();
-		this.reindexList(repeater);
+		let items = this.repeaters.get(repeater);
+		if (items) {
+			this.reindexList(items.ui.items);
+		}
+
 		this.a11y.announce('Row removed');
 	}
 	checkForTagLists(form) {
@@ -1133,38 +1342,41 @@
 			const requiredValue = field.dataset.dependsValue;
 			const operator = field.dataset.dependsOperatior??'==';
 
+			let formData = this.forms.get(form.dataset.formId);
+
 			if (!this.dependencies.has(dependsOn)) {
-				let element = document.querySelector(`[field="${dependsOn}"]`);
-				if (element) {
-					this.dependencies.set(dependsOn, {
-						element: element,
-						items: []
-					});
+				if (Object.hasOwn(formData.ui.fields, dependsOn)) {
+					this.dependencies.set(dependsOn, []);
 				}
 			}
 			let dependency = this.dependencies.get(dependsOn);
-			dependency.items.push({
-				field: field,
-				form: form.dataset.formId,
-				requiredValue: requiredValue,
-				operator: operator
-			});
-			this.dependencies.set(dependsOn, dependency);
-			this.checkFieldDependency(dependency, dependsOn);
+			if (dependency) {
+				dependency.push({
+					field:	field,
+					form: form.dataset.formId,
+					requiredValue: requiredValue,
+					operator: operator
+				});
+				this.dependencies.set(dependsOn, dependency);
+			}
+
+			this.checkFieldDependency(field, dependsOn);
 		});
 	}
 	checkFieldDependency(dependentField, controlFieldName) {
+		const form = this.getForm(dependentField);
 		const controlField = this.dependencies.get(controlFieldName);
 		if (!controlField) return;
 
-		const controlValue = this.getFieldCheckedValue(controlField.element);
+
+		const controlValue = this.getFieldValue(form.ui.fields[controlFieldName]);
 		const shouldShow = this.evaluateCondition(
 			controlValue,
-			dependentField.requiredValue,
-			dependentField.operator
+			dependentField.dataset.dependsValue,
+			dependentField.dataset.dependsOperatior
 		);
 
-		this.toggleFieldVisibility(dependentField.field, shouldShow);
+		this.toggleFieldVisibility(dependentField, shouldShow);
 	}
 	evaluateCondition(value, requiredValue, operator) {
 		const fieldStr = String(value || '');
@@ -1259,16 +1471,17 @@
 
 	checkForTabs(form, config) {
 		if (window.jvbTabs && form.querySelector('nav.tabs')) {
+
 			config.tabs = window.jvbTabs.registerTab(form, {
-				preCheck: (section, tabConfig) => {
-					return this.validateStep(section, config);
+				preCheck: (activeSection, switchTo, tabConfig) => {
+					return this.validateStep(activeSection, switchTo, tabConfig);
 				}
 			});
 			config.ui.tabs = window.uiFromSelectors(this.selectors.tabs, form);
 			config.ui.tabs.sections = Array.from(form.querySelectorAll(this.selectors.tabs.sections));
 			config.ui.tabs.inputs = {};
 			config.ui.tabs.sections.forEach(section => {
-				config.ui.tabs.inputs[section.dataset.tab] = Array.from(section.querySelectorAll(this.inputs));
+				config.ui.tabs.inputs[section.dataset.tab] = Array.from(section.querySelectorAll(this.inputSelectors));
 			});
 			config.ui.tabs.buttons = Array.from(form.querySelectorAll(this.selectors.tabs.buttons));
 
@@ -1277,13 +1490,14 @@
 					if (config.ui.tabs.progress) {
 						const section = config.ui.tabs.sections.filter(section => section.dataset.tab === data.current)[0]??false;
 						if (!section) return;
-						const step = section.dataset.step;
-						const total = config.ui.sections.length;
+						const step = section.dataset.step - 1;
+						const total = config.ui.tabs.sections.length;
 
 						window.showProgress(
 							config.ui.tabs.progress,
 							step,
-							total
+							total,
+							`Step ${step + 1} of ${total}`
 						);
 					}
 				}
@@ -1291,8 +1505,24 @@
 			this.forms.set(config.id, config);
 		}
 	}
-	validateStep(section, config) {
-		const formId = section.closest('[data-form-id]')?.dataset.formId;
+	validateStep(currentSection, switchTo, config) {
+		let doit = false;
+		if (config.ui.sections) {
+			config.ui.sections.forEach((section, index) => {
+				if (section === currentSection) {
+					let prev = index -1;
+					if (prev >= 0) {
+						let prevSection = config.ui.sections[prev];
+						if (prevSection && prevSection.dataset.tab === switchTo) {
+							doit = true;
+						}
+					}
+				}
+			});
+		}
+		if (doit) return true;
+
+		const formId = currentSection.closest('[data-form-id]')?.dataset.formId;
 		if (!formId) return true;
 
 		const form = this.forms.get(formId);
@@ -1302,7 +1532,7 @@
 			.filter(item =>
 				item &&
 				item.form === formId &&
-				item.section === section.dataset.tab &&
+				item.section === currentSection.dataset.tab &&
 				!item.element.closest('[hidden]')
 			);
 
@@ -1316,11 +1546,21 @@
 	 * @param {HTMLElement} container
 	 */
 	reindexList(container) {
-		const fieldName = container.dataset.field || container.dataset.repeaterId || container.dataset.tagListId;
+		let fieldName = container.dataset.field || container.dataset.repeaterId || container.dataset.tagListId;
+		if (!fieldName) {
+			let parent = container.closest('[data-field]');
+			fieldName = parent.dataset.field || parent.dataset.repeaterId || parent.dataset.tagListId;
+		}
 
 		Array.from(container.children).forEach((item, index) => {
 			item.dataset.index = `${index}`;
 
+			let rowNum = item.querySelector('.row-number');
+			if (rowNum) {
+				rowNum.textContent = `#${index + 1}`;
+			}
+			let rowTitle = item.querySelector('.row-title');
+			let rowLabel = rowTitle ? rowTitle.dataset.label : null;
 			// Find ALL inputs within this item, not just direct children
 			const inputs = item.querySelectorAll('input, select, textarea');
 
@@ -1329,14 +1569,17 @@
 				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();
+				const inputField = Object.hasOwn(input.dataset, 'field') ? input.dataset.field : input.name.split(':').pop();
+				if (inputField === rowLabel) {
+					rowTitle.textContent = window.escapeHtml(input.value);
+				}
 
 				// Re-prefix with the new index, passing item as wrapper
 				window.prefixInput(
 					input,
-					`${fieldName}:${index}:`,
+					`${fieldName}:${index}:${inputField}`,
 					item,
-					false,
+					true,
 					true
 				);
 			});
@@ -1364,6 +1607,27 @@
 		const value = this.getFieldValue(field);
 		this.updateItem(field.dataset.field, value, form);
 	}
+
+	//TODO: Store these selectors in the repeater object maybe?
+	maybeUpdateCollectionDisplay(field) {
+		let parent = field.closest('[data-repeater-id],[data-tag-list-id]');
+		if (!parent) {
+			return;
+		}
+		if (parent.dataset.fieldType === 'repeater') {
+			let row = field.closest('.repeater-row');
+			if (!row) return;
+
+			let title = row.querySelector('.row-title');
+			if (!title || !title.dataset.label) return;
+			console.log(field.dataset.field.split(':').pop());
+			console.log(title.dataset.label);
+			if (field.dataset.field.split(':').pop() === title.dataset.label) {
+				title.textContent = this.getFieldValue(field.querySelector(this.inputSelectors));
+			}
+		}
+
+	}
 	/**********************************************************************
 	 VALIDATION
 	 **********************************************************************/
@@ -1400,6 +1664,12 @@
 
 		field.classList.remove('has-success');
 		field.classList.add('has-error');
+		field.scrollIntoView({
+			behavior: "smooth",
+			block: "center",
+			inline: "nearest"
+		});
+		field.querySelector(this.inputSelectors)?.focus();
 
 		if (item.ui.message) {
 			item.ui.message.hidden = false;
@@ -1706,6 +1976,10 @@
 		let value = [];
 		Array.from(items.children).forEach(row => {
 			let rowData = {};
+			row.querySelectorAll('input[type="hidden"]').forEach(hidden => {
+				rowData[hidden.name] = hidden.value;
+			});
+
 			row.querySelectorAll('[data-field]').forEach(field => {
 				if (!ignore.includes(field.dataset.field)) {
 					const input = this.getFieldInput(field);
@@ -2086,6 +2360,9 @@
 	window.auth.subscribe(event => {
 		if (event === 'auth-loaded') {
 			window.jvbForm = new FormController();
+			document.querySelectorAll('form[data-auto]').forEach(form => {
+				window.jvbForm.registerForm(form, {});
+			});
 		}
 	});
 });

--
Gitblit v1.10.0