From 0dfe1d8afafc59c4a5559c498342668d5a58d6ef Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Thu, 23 Jul 2026 22:41:41 +0000
Subject: [PATCH] =Still working away at the Integrations overhaul
---
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..e5f3868 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