From c19264ac916707096fe294d996a1b7fb85206b34 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sun, 08 Mar 2026 19:55:31 +0000
Subject: [PATCH] =Furthering along the complete refactor. Added some basic setup for is_content taxonomies, including a page for an archive of the terms.
---
assets/js/concise/FormController.js | 427 +++++++++++++++++++++++++++++++++++------------------
1 files changed, 280 insertions(+), 147 deletions(-)
diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 9a3f632..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: {
@@ -73,11 +73,12 @@
},
tagList: {
tagList: '.field.tag-list', //querySelectorAll
- input: '.tag-input-row',
+ input: '.row',
add: '.add-tag',
remove: '.remove-tag',
label: '.tag-label',
items: '.tag-items',
+ item: '.tag-item',
inputs: this.inputSelectors, //querySelectorAll
value: 'input[type="hidden"]' //querySelectorAll
},
@@ -91,7 +92,7 @@
input: 'input[type="number"]'
},
limits: {
- hasLimit: '[data-limit]',
+ hasLimit: '[data-maxlength]',
limit: '.limit',
current: '.current',
}
@@ -325,6 +326,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);
@@ -347,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;
@@ -377,12 +406,12 @@
if (this.subscribers.size > 0) {
e.preventDefault();
- console.log('Cancelling scheduled backup and manually backing up');
- this.cancelBackup();
- await this.backup();
- const storedData = await this.store.get(form.id);
if (form.options.cache) {
+ this.cancelBackup();
+ await this.backup();
+ const storedData = await this.store.get(form.id);
+
this.notify('form-submit', {
config: form,
data: storedData.changes
@@ -605,8 +634,11 @@
this.removeQuantityListeners(item.element);
break;
}
+
+ if (check.has(item.id)) {
+ check.delete(item.id);
+ }
});
- check.delete(item.id);
}
}
@@ -709,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);
});
@@ -754,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;
@@ -770,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 => {
@@ -797,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(
@@ -811,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);
});
}
},
@@ -827,6 +863,7 @@
}
});
}
+
repeater.dataset.repeaterId = config.id;
this.addRepeaterListeners(repeater);
this.repeaters.set(config.id, config);
@@ -843,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) {
@@ -868,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(
@@ -883,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) {
@@ -892,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);
});
@@ -900,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]');
@@ -1077,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});
}
@@ -1179,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
@@ -1343,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');
@@ -1372,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}`
@@ -1385,7 +1501,6 @@
window.jvbA11y.announce(announcement);
}
- // Trigger custom event
form.dispatchEvent(new CustomEvent('jvb-form-error', {
detail: data
}));
@@ -1448,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);
@@ -1484,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('[]')) {
@@ -1541,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');
@@ -1582,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
@@ -1628,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);
@@ -1750,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) {
@@ -1892,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