From a24a06002081ad71a78ffeff9072725ba39cf121 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 17 Feb 2026 20:05:31 +0000
Subject: [PATCH] =minor changes, particularly around the JVB_CHILD_URL pattern
---
assets/js/concise/FormController.js | 371 ++++++++++++++++++++++++++++++++--------------------
1 files changed, 226 insertions(+), 145 deletions(-)
diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 07185b6..70e5b6a 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: {
@@ -78,6 +78,7 @@
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',
}
@@ -326,6 +327,25 @@
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);
@@ -334,11 +354,6 @@
});
}
- if (Object.hasOwn(field.dataset, 'repeater-id') || Object.hasOwn(field.dataset,'tag-list-id')) {
- this.updateCollectionField(field);
- return;
- }
-
let form = this.getForm(e.target);
this.updateItem(field.dataset.field, this.getFieldValue(e.target), form);
}
@@ -353,10 +368,18 @@
window.debouncer.cancel(`form:${form.id}:validate:${fieldName}`);
this.validateField(e.target);
+ // 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) return;
@@ -718,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);
});
@@ -763,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;
@@ -779,20 +803,20 @@
} 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) {
@@ -807,6 +831,7 @@
element: repeater,
field: this.getField(repeater),
sortable: false,
+ rows: []
};
if (!config.ui.add) return;
@@ -822,9 +847,8 @@
let index = config.ui.items?.children?.length??0;
el.dataset.index = index;
-
manyRefs.inputs?.forEach(input => {
- window.prefixInput(input, `${data.repeater.dataset.fieldName}:${index}:`, el);
+ window.prefixInput(input, `${data.repeater.dataset.field}:${index}:`, el, false, true);
});
}
},
@@ -839,6 +863,7 @@
}
});
}
+
repeater.dataset.repeaterId = config.id;
this.addRepeaterListeners(repeater);
this.repeaters.set(config.id, config);
@@ -853,18 +878,26 @@
}
handleRepeaterClick(e) {
if (e.target.matches(this.selectors.repeater.add)) {
- console.log('Add Repeater Row');
this.addRepeaterRow(e.target.closest('[data-repeater-id]'));
} else if (e.target.matches(this.selectors.repeater.remove)) {
- console.log('Remove Repeater Row');
this.removeRepeaterRow(e.target.closest('[data-index]'));
}
}
addRepeaterRow(repeater) {
let data = {};
data.repeater = repeater;
- repeater.append(this.templates.create(repeater.dataset.repeaterId, data));
- this.initializeFields(repeater, this.getField(repeater).config??{});
+ 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) {
@@ -885,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(
@@ -900,8 +934,8 @@
let index = config.ui.items?.children?.length??0;
el.dataset.index = index;
manyRefs.inputs?.forEach(input => {
- let wrapper = window.closest('.tag-item');
- window.prefixInput(input, `${el.dataset.fieldName}:${index}:`, wrapper)
+ let wrapper = input.closest('.tag-item');
+ window.prefixInput(input, `${data.fieldName}:${index}:`, wrapper, false, true)
});
if (refs.label) {
@@ -910,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);
});
@@ -918,93 +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.updateCollectionField(tagList);
-
- 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]');
@@ -1097,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});
}
@@ -1218,7 +1291,9 @@
window.prefixInput(
input,
`${fieldName}:${index}:`,
- item // Pass the item as wrapper for label lookup
+ item,
+ false,
+ true
);
});
});
@@ -1242,7 +1317,7 @@
if (!form) return;
// Get all current data for the collection
- const value = this.getFieldValue(field.querySelector('input, select, textarea'));
+ const value = this.getFieldValue(field);
this.updateItem(field.dataset.field, value, form);
}
/**********************************************************************
@@ -1395,28 +1470,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');
@@ -1424,12 +1491,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}`
@@ -1437,7 +1501,6 @@
window.jvbA11y.announce(announcement);
}
- // Trigger custom event
form.dispatchEvent(new CustomEvent('jvb-form-error', {
detail: data
}));
@@ -1500,6 +1563,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);
@@ -1536,10 +1600,13 @@
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('[]')) {
@@ -1593,20 +1660,31 @@
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');
@@ -1634,13 +1712,14 @@
});
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
@@ -1680,6 +1759,8 @@
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);
@@ -1802,7 +1883,7 @@
* Format hidden field types (upload, selector)
*/
formatHiddenFieldForSummary(value, input, fieldType) {
- if (fieldType === 'upload') {
+ if (['upload', 'gallery', 'image'].includes(fieldType)) {
// Get upload preview images if available
const uploadField = input.field?.querySelector('[data-upload-field]');
if (uploadField) {
@@ -1944,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