From 80d8479fd04c9a06a98f7cf4aedaa367984ac038 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sun, 08 Feb 2026 22:15:13 +0000
Subject: [PATCH] =Form.php upload render fix
---
assets/js/concise/FormController.js | 922 ++++++++++++++++++++++++++++++++++++++++++++++-----------
1 files changed, 746 insertions(+), 176 deletions(-)
diff --git a/assets/js/concise/FormController.js b/assets/js/concise/FormController.js
index 76ab6c3..bac1cd6 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,7 @@
if (e.target.closest('[data-ignore]') || this.isRestoring) return;
let field = this.getField(e.target);
+
//Dependencies
if (this.dependencies.has(field.dataset.field)) {
let dependency = this.dependencies.get(field.dataset.field);
@@ -319,9 +335,12 @@
});
}
+ if (field.dataset.fieldType === 'repeater' || field.dataset.fieldType === 'tag-list') {
+ this.updateCollectionField(field);
+ return;
+ }
+
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 +354,26 @@
window.debouncer.cancel(`form:${form.id}:validate:${fieldName}`);
this.validateField(e.target);
- if (form.options.cache) {
- this.updateItem(fieldName, this.getFieldValue(e.target), form);
- }
+ this.updateItem(fieldName, this.getFieldValue(e.target), form);
}
handleInput(e){
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 +384,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 +427,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 +507,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 +612,11 @@
this.removeQuantityListeners(item.element);
break;
}
+
+ if (check.has(item.id)) {
+ check.delete(item.id);
+ }
});
- check.delete(item.id);
}
}
@@ -575,12 +638,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 +651,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 +675,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 +698,8 @@
}
);
}
+
+
initializeFields(container, config = null) {
const fieldHandlers = {
'[data-editor]': () => this.checkForQuill(container,config),
@@ -636,7 +708,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)
};
@@ -692,14 +764,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 +780,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 => {
@@ -737,7 +810,7 @@
sortable: false,
};
- if (!config.ui.addButton) return;
+ if (!config.ui.add) return;
let template = repeater.querySelector('template');
this.templates.define(
@@ -749,8 +822,10 @@
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);
});
}
},
@@ -781,11 +856,15 @@
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;
+ repeater.append(this.templates.create(repeater.dataset.repeaterId, data));
+ let form = this.getForm(repeater);
+ this.initializeFields(repeater, form);
this.a11y.announce('Row added');
}
removeRepeaterRow(row) {
@@ -806,6 +885,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 +901,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)
});
if (refs.label) {
@@ -830,7 +911,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 +920,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 +1088,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 +1130,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 = field.querySelector('input, textarea, select');
+ 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 +1177,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 +1239,51 @@
* @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 // Pass the item as wrapper for label lookup
+ );
});
});
- //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.querySelector('input, select, textarea'));
+ this.updateItem(field.dataset.field, value, form);
}
/**********************************************************************
VALIDATION
@@ -1194,6 +1348,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 +1479,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 +1519,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);
@@ -1287,11 +1569,53 @@
case 'true-false':
return element.value === '1'||element.value === 'on'||element.value ==='true';
-
+ 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;
@@ -1347,6 +1671,235 @@
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':
+ // 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 (fieldType === 'upload') {
+ // 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 +1928,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 +1973,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