class FormController {
|
constructor() {
|
this.a11y = window.jvbA11y;
|
this.error = window.jvbError;
|
this.queue = window.jvbQueue;
|
this.populate = window.jvbPopulate;
|
|
this.changes = new Map();
|
this.forms = new Map();
|
this.inputs = new Map();
|
this.repeaters = new Map();
|
this.tagLists = new Map();
|
this.charLimits = new Map();
|
this.quantityFields = new Map();
|
this.quillInstances = new Map(); // formId -> Set of quill instances
|
this.dependencies = new Map();
|
|
this.subscribers = new Set();
|
|
this.isRestoring = false;
|
this.hasListeners = false;
|
this.summaryTemplate = false;
|
|
this.init();
|
}
|
init() {
|
this.templates = window.jvbTemplates;
|
this.initElements();
|
this.initListeners();
|
this.initStore();
|
this.initValidators();
|
}
|
initElements() {
|
this.inputSelectors = 'input, textarea, select';
|
this.selectors = {
|
tabs: {
|
nav: 'nav.tabs',
|
sections: '.tab.content', //querySelectorAll
|
progress: {
|
progress: '.progress',
|
fill: '.progress .fill',
|
details: '.progress .details',
|
icon: '.progress .icon'
|
},
|
buttons: 'nav.tabs button',
|
},
|
dependsOn: '[data-depends-on]',
|
forms: {
|
status: {
|
status: '.fstatus',
|
message: '.fstatus .message',
|
icon: '.fstatus .icon',
|
actions: '.fstatus .actions'
|
}
|
},
|
inputs: this.inputSelectors, //querySelectorAll
|
fields: {
|
field: '.field', //querySelectorAll
|
label: 'label',
|
success: '.success',
|
error: '.success',
|
message: '.validation-message',
|
},
|
repeater: {
|
repeater: '.repeater', //querySelectorAll
|
header: '.repeater-row-header',
|
remove: '.remove-row',
|
add: '.add-repeater-row',
|
template: 'template',
|
items: '.repeater-items',
|
inputs: this.inputSelectors //querySelectorAll
|
},
|
tagList: {
|
tagList: '.field.tag-list', //querySelectorAll
|
input: '.tag-input-row',
|
add: '.add-tag',
|
remove: '.remove-tag',
|
label: '.tag-label',
|
items: '.tag-items',
|
inputs: this.inputSelectors, //querySelectorAll
|
value: 'input[type="hidden"]' //querySelectorAll
|
},
|
tag: {
|
label: '.tag-label'
|
},
|
number: {
|
number: '.field div.quantity',
|
increase: 'button.increase',
|
decrease: 'button.decrease',
|
input: 'input[type="number"]'
|
},
|
limits: {
|
hasLimit: '[data-limit]',
|
limit: '.limit',
|
current: '.current',
|
}
|
};
|
}
|
initListeners() {
|
this.clickHandler = this.handleClick.bind(this);
|
this.changeHandler = this.handleChange.bind(this);
|
this.blurHandler = this.handleBlur.bind(this);
|
this.inputHandler = this.handleInput.bind(this);
|
this.submitHandler = this.handleSubmit.bind(this);
|
this.quantityClick = this.handleQuantityClick.bind(this);
|
this.repeaterClick = this.handleRepeaterClick.bind(this);
|
this.tagListClick = this.handleTagListClick.bind(this);
|
this.tagListInput = this.handleTagListInput.bind(this);
|
}
|
addFormListeners(form) {
|
form.addEventListener('click', this.clickHandler);
|
form.addEventListener('change', this.changeHandler);
|
form.addEventListener('input', this.inputHandler);
|
form.addEventListener('blur', this.blurHandler);
|
form.addEventListener('submit', this.submitHandler);
|
}
|
removeFormListeners(form) {
|
form.removeEventListener('click', this.clickHandler);
|
form.removeEventListener('change', this.changeHandler);
|
form.removeEventListener('input', this.inputHandler);
|
form.removeEventListener('blur', this.blurHandler);
|
form.removeEventListener('submit', this.submitHandler);
|
}
|
initStore() {
|
const store = window.jvbStore.register(
|
'forms',
|
{
|
storeName: 'forms',
|
keyPath: 'id',
|
indexes: [
|
{ name: 'src', keyPath: 'src'},
|
{ name: 'timestamp', keyPath: 'timestamp' },
|
{ name: 'formType', keyPath: 'type' }
|
],
|
TTL: 7 * 24 * 60 * 1000, //7 days
|
});
|
this.store = store.forms;
|
|
this.store.subscribe((event, data)=> {
|
if (event === 'data-loaded') {
|
let stored = this.store.getFiltered();
|
|
let pending = stored.filter(form=> form.src === window.location.pathname);
|
for (let form of pending) {
|
this.showPendingNotification(form.id, form.changes);
|
}
|
} else if (event === 'operation-status' && data.status === 'completed') {
|
if (data.config) {
|
this.store.remove(data.config.id);
|
}
|
}
|
});
|
}
|
showPendingNotification(formId, changes) {
|
let form = this.forms.get(formId);
|
if (!form) return;
|
let element = form.element;
|
if (!element) {
|
console.warn(`Form element not found for: ${formId}`);
|
return;
|
}
|
|
const notification = document.createElement('div');
|
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>`;
|
|
element.insertBefore(notification, form.ui.status.status);
|
|
notification.querySelector('.restore').addEventListener('click', async () => {
|
this.isRestoring = true;
|
|
new this.populate(element, changes);
|
this.a11y.announce('Previous changes restored');
|
|
this.isRestoring = false;
|
notification.remove();
|
});
|
|
notification.querySelector('.discard').addEventListener('click', async () => {
|
await this.store.remove(formId);
|
this.a11y.announce('Previous changes discared');
|
notification.remove();
|
});
|
|
}
|
initValidators() {
|
this.validators = {
|
email: {
|
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
message: 'Please enter a valid email address'
|
},
|
url: {
|
pattern: /^https?:\/\/.+\..+/,
|
message: 'Please enter a valid URL starting with https://'
|
},
|
phone: {
|
pattern: /^[\d\s\-+().]+$/,
|
message: 'Please enter a valid phone number'
|
},
|
number: {
|
test: (value, fieldWrapper) => {
|
const num = parseFloat(value);
|
if (isNaN(num)) return 'Please enter a valid number';
|
|
const min = fieldWrapper.dataset.min;
|
const max = fieldWrapper.dataset.max;
|
|
if (min !== undefined && num < parseFloat(min)) {
|
return `Value must be at least ${min}`;
|
}
|
if (max !== undefined && num > parseFloat(max)) {
|
return `Value must be at most ${max}`;
|
}
|
return true;
|
}
|
},
|
text: {
|
test: (value, fieldWrapper) => {
|
const minLength = fieldWrapper.dataset.minlength;
|
const maxLength = fieldWrapper.dataset.maxlength;
|
|
if (minLength && value.length < parseInt(minLength)) {
|
return `Must be at least ${minLength} characters`;
|
}
|
if (maxLength && value.length > parseInt(maxLength)) {
|
return `Must be no more than ${maxLength} characters`;
|
}
|
return true;
|
}
|
}
|
};
|
}
|
validateField(input) {
|
const result = this.performValidation(input);
|
this.updateValidationUI(input, result);
|
return result.isValid;
|
}
|
performValidation(input) {
|
const field = input.closest('.field');
|
const value = this.getFieldValue(input);
|
|
if (!value && !input.required) {
|
return { isValid: true, message: '' };
|
}
|
|
if (input.required && !value) {
|
return { isValid: false, message: 'This field is required' };
|
}
|
|
if(input.checkValidity && !input.checkValidity()){
|
return {isValid: false, message: input.validationMessage};
|
}
|
|
if (value && Object.hasOwn(field.dataset, 'pattern')) {
|
const regex = new RegExp(field.dataset.pattern);
|
if (!regex.test(value)) {
|
return {isValid: false, message: field.dataset.validationMessage || 'Invalid format'};
|
}
|
}
|
|
if (Object.hasOwn(field.dataset, 'validate') || input.type) {
|
const validator = this.validators[field.dataset.validate||input.type];
|
|
if (validator.pattern && !validator.pattern.test(value)) {
|
return {isValid: false, message: validator.message};
|
}
|
|
if (validator.test) {
|
const result = validator.test(value, field);
|
if (result !== true) {
|
return {isValid: false, message: result};
|
}
|
}
|
}
|
|
return {isValid: true, message: ''};
|
}
|
updateValidationUI(input, result) {
|
if (result.isValid) {
|
this.showSuccess(input, result.message);
|
} else {
|
this.showError(input, result.message);
|
}
|
}
|
|
handleClick(e) {
|
let form = this.getForm(e.target);
|
if (!form) return;
|
|
const itemAction = window.targetCheck(e, '[data-action]');
|
if (itemAction) {
|
let action = itemAction.dataset.action;
|
switch (action) {
|
case 'clear-form':
|
this.store.delete(form.id);
|
form.element.reset();
|
form.ui.status.status.hidden = true;
|
this.a11y.announce('Form cleared, starting fresh');
|
break;
|
case 'dismiss-restore':
|
form.ui.status.status.hidden = true;
|
break;
|
}
|
}
|
}
|
|
handleChange(e) {
|
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);
|
dependency.items.forEach(item => {
|
this.checkFieldDependency(item, field.dataset.field);
|
});
|
}
|
|
let form = this.getForm(e.target);
|
//Autosave
|
if (!form || !form.options.cache) return;
|
this.updateItem(field.dataset.field, this.getFieldValue(e.target), form);
|
}
|
|
handleBlur(e) {
|
if (e.target.closest('[data-ignore]') || this.isRestoring) return;
|
let form = this.getForm(e.target);
|
if (!form) return;
|
|
let field = this.getField(e.target);
|
let fieldName = field.dataset.field;
|
window.debouncer.cancel(`form:${form.id}:validate:${fieldName}`);
|
this.validateField(e.target);
|
|
if (form.options.cache) {
|
this.updateItem(fieldName, this.getFieldValue(e.target), form);
|
}
|
}
|
|
handleInput(e){
|
let form = this.getForm(e.target);
|
if (!form || !form.options.cache) return;
|
|
let field = this.getField(e.target);
|
if (!field) return;
|
|
this.showFormStatus(form, 'pending');
|
window.debouncer.schedule(
|
`form:${form.id}:validate:${field.dataset.field}`,
|
() => this.validateField.bind(this),
|
500
|
);
|
}
|
|
async handleSubmit(e) {
|
let form = this.getForm(e.target);
|
if (!form) return;
|
|
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.showSummary) {
|
const storedData = await this.store.get(form.id);
|
this.showSummary(form.id, {
|
config: form,
|
data: storedData?.changes || {}
|
});
|
}
|
}
|
|
/**
|
* Updates the item, schedules caching if
|
* @param name
|
* @param value
|
* @param form
|
*/
|
updateItem(name, value, form) {
|
if (!this.changes.has(form.id)) {
|
this.changes.set(form.id, {
|
id: form.id,
|
timestamp: Date.now(),
|
src: window.location.pathname,
|
changes: {},
|
});
|
}
|
let changes = this.changes.get(form.id);
|
changes.changes[name] = value;
|
this.changes.set(form.id, changes);
|
this.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();
|
}
|
},
|
2000
|
);
|
}
|
|
saveCache(formId) {
|
if (!this.changes.has(formId)) return;
|
let changes = this.changes.get(formId);
|
if (changes.size === 0) return;
|
this.store.save(changes).then(()=>{});
|
this.changes.delete(formId);
|
}
|
|
|
/**
|
* Register a form for handling
|
* @param {HTMLElement} form
|
* @param {object} options
|
*/
|
registerForm(form, 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_');
|
}
|
const formId = form.dataset.formId;
|
|
this.addFormListeners(form);
|
|
const config = {
|
element: form,
|
id: formId,
|
status: '',
|
options: {
|
autoUpload: false,
|
delay: options.delay??1500,
|
endpoint: options.save??form.dataset.save??'',
|
formStatus: options.showStatus??true,
|
showSummary: false,
|
cache: options.cache??true,
|
},
|
ui: window.uiFromSelectors(this.selectors.forms, form)
|
};
|
|
if (config.showSummary && !this.summaryTemplate) {
|
this.defineSummaryTemplate();
|
}
|
|
this.initializeFields(form, config);
|
this.forms.set(formId, config);
|
|
return config;
|
}
|
clearForm(formId) {
|
const config = this.forms.get(formId);
|
if (!config) return;
|
|
if (config.unsubscribeTabs) {
|
config.unsubscribeTabs();
|
}
|
if(config.tabs) {
|
window.jvbTabs.removeTab(config.element);
|
}
|
|
if (config.cache && this.changes.has(formId)) this.saveCache(formId);
|
|
// Cleanup items
|
for (let [id, input] of this.inputs.entries()) {
|
if (input.form === formId) {
|
this.inputs.delete(id);
|
}
|
}
|
// Clean up dependencies for this form
|
this.dependencies.forEach((dependency, fieldName) => {
|
dependency.items = dependency.items.filter(item => item.form !== formId);
|
|
// Remove the dependency entry entirely if no items left
|
if (dependency.items.length === 0) {
|
this.dependencies.delete(fieldName);
|
}
|
});
|
|
if (Object.hasOwn(config, 'hasQuill') && this.quillInstances.has(formId)) {
|
const instances = this.quillInstances.get(formId);
|
instances.forEach(quillInstance => {
|
// Disable the editor
|
quillInstance.disable();
|
|
// Remove all event listeners
|
quillInstance.off('text-change');
|
quillInstance.off('selection-change');
|
|
// Get the container elements
|
const container = quillInstance.container.parentElement;
|
const toolbar = container?.querySelector('.ql-toolbar');
|
|
// Remove toolbar
|
if (toolbar) {
|
toolbar.remove();
|
}
|
|
// Clear the editor content
|
quillInstance.setText('');
|
|
// Remove container
|
if (container && container.classList.contains('editor-container')) {
|
const textarea = container.nextElementSibling;
|
if (textarea?.tagName === 'TEXTAREA') {
|
textarea.style.display = '';
|
}
|
container.remove();
|
}
|
});
|
|
this.quillInstances.delete(formId);
|
}
|
let checks = {
|
repeater: this.repeaters,
|
tagList: this.tagLists,
|
charLimit: this.charLimits,
|
quantity: this.quantityFields
|
};
|
for (let [type, check] of Object.entries(checks)) {
|
if (check.size === 0) continue;
|
let hasAny = Array.from(check.values()).filter(item => item.form === formId);
|
if (hasAny.length > 0) {
|
hasAny.forEach(item => {
|
switch (type) {
|
case 'repeater':
|
this.removeRepeaterListeners(item.element);
|
break;
|
case 'tagList':
|
this.removeTagListListeners(item.element);
|
break;
|
case 'charLimit':
|
this.removeCharacterLimitListeners(item.element);
|
break;
|
case 'quantity':
|
this.removeQuantityListeners(item.element);
|
break;
|
}
|
});
|
check.delete(item.id);
|
}
|
}
|
|
|
this.removeFormListeners(config.element);
|
this.forms.delete(formId);
|
|
window.debouncer.cancel(`form_changes`);
|
}
|
defineSummaryTemplate() {
|
this.summaryTemplate = true;
|
let form = this;
|
this.templates.define(
|
'formSummary',
|
{
|
refs: {
|
result: '.result',
|
h3: 'h3',
|
p: 'p',
|
},
|
setup({ el, refs, manyRefs, data }) {
|
const skipFields = ['sendAll', ...form.ignore];
|
|
for (let [key, value] of Object.entries(data.changes)) {
|
if (skipFields.includes(key) || this.isEmptyValue(value)) continue;
|
|
let input = Array.from(this.inputs.values())
|
.find(temp => temp.field?.dataset.field === key);
|
if (!input) continue;
|
|
let entry = refs.result.cloneNode(true);
|
let title = entry.querySelector('h3');
|
let p = entry.querySelector('p');
|
|
title.textContent = input.label.textContent;
|
|
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}`;
|
}
|
|
el.append(entry);
|
}
|
let uploads = data.config?.element?.querySelectorAll('[data-upload-field]');
|
if (uploads) {
|
uploads.forEach(upload => {
|
let label = upload.querySelector('h2')?.textContent??'Upload:';
|
let imgs = upload.querySelectorAll('.item-grid.preview img');
|
if (imgs) {
|
let entry = refs.result.cloneNode(true);
|
let title = field.querySelector('h3');
|
let p = field.querySelector('p');
|
p?.remove();
|
if (title) title.textContent = label;
|
imgs.forEach(img => {
|
img = img.cloneNode(true);
|
entry.append(img);
|
});
|
el.append(entry);
|
}
|
});
|
}
|
|
refs.result?.remove();
|
data.config.element.after(el);
|
window.fade(data.config.element, false);
|
}
|
}
|
);
|
}
|
initializeFields(container, config = null) {
|
const fieldHandlers = {
|
'[data-editor]': () => this.checkForQuill(container,config),
|
'div.quantity': () => this.checkForQuantity(container),
|
'.repeater': () => this.checkForRepeaters(container, config),
|
'.field.tag-list': () => this.checkForTagLists(container),
|
'[data-depends-on]': () => this.checkForConditionalFields(container),
|
'[data-limit]': () => this.checkForCharacterLimits(container),
|
'[data-uploader]': () => this.checkForImageUploads(container, config),
|
'nav.tabs': () => this.checkForTabs(container, config),
|
'[data-type="selector"]': () => this.checkForSelectors(container)
|
};
|
|
for (const [selector, handler] of Object.entries(fieldHandlers)) {
|
if (container.querySelector(selector)) {
|
handler();
|
}
|
}
|
|
let inputs = Array.from(container.querySelectorAll(this.inputSelectors));
|
inputs.map(input => {
|
this.getItem(input, config?.id);
|
});
|
}
|
checkForQuill(form, config) {
|
if (!form.querySelector('[data-editor]')) return;
|
if (config && !Object.hasOwn(config, 'hasQuill')){
|
config.hasQuill = true;
|
this.forms.set(config.id, config);
|
}
|
|
if (!this.quillInstances.has(config.id)) {
|
this.quillInstances.set(config.id, new Set());
|
}
|
|
const instances = window.jvbQuill(form);
|
instances.forEach(instance => {
|
this.quillInstances.get(config.id).add(instance);
|
});
|
}
|
checkForQuantity(form) {
|
if (!form.querySelector(this.selectors.number.number)) return;
|
form.querySelectorAll(this.selectors.number.number).forEach(num => {
|
let config = {
|
id: window.generateID('quant'),
|
form: form.dataset.formId,
|
ui: window.uiFromSelectors(this.selectors.number, num),
|
element: num
|
};
|
num.dataset.numId = config.id;
|
this.quantityFields.set(config.id, config);
|
this.addQuantityListeners(num);
|
});
|
}
|
addQuantityListeners(el) {
|
el.addEventListener('click', this.quantityClick);
|
}
|
removeQuantityListeners(el) {
|
el.removeEventListener('click', this.quantityClick);
|
}
|
handleQuantityClick(e) {
|
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)) {
|
change++;
|
} else if (conf.decrease.contains(e.target)) {
|
change--;
|
}
|
if (change === 0) return;
|
let field = this.getField(e.target);
|
let step = conf.input.step;
|
step = Math.max(step, 1);
|
if (e.ctrlKey && e.shiftKey) {
|
step = step * 50;
|
} else if (e.ctrlKey) {
|
step = step *5;
|
} else if (e.shiftKey) {
|
step = step * 10;
|
}
|
let value = (conf.input.value === '') ? 0 : parseFloat(conf.input.value);
|
conf.input.value = (value + (step * change));
|
|
value = parseFloat(conf.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;
|
} else {
|
if (conf.decrease.disabled) conf.decrease.disabled = false;
|
if (conf.increase.disabled) conf.increase.disabled = false;
|
}
|
}
|
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'),
|
ui: window.uiFromSelectors(this.selectors.repeater, repeater),
|
form: form.dataset.formId,
|
element: repeater,
|
field: this.getField(repeater),
|
sortable: false,
|
};
|
|
if (!config.ui.addButton) return;
|
|
let template = repeater.querySelector('template');
|
this.templates.define(
|
template.className,
|
{
|
manyRefs: {
|
inputs: this.inputSelectors,
|
},
|
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}:`)
|
});
|
}
|
},
|
);
|
|
if (window.Sortable) {
|
config.sortable = new Sortable(repeater, {
|
handle: this.selectors.repeater.header,
|
animation: 150,
|
onEnd: () => {
|
this.reindexList(repeater);
|
}
|
});
|
}
|
repeater.dataset.repeaterId = config.id;
|
this.addRepeaterListeners(repeater);
|
this.repeaters.set(config.id, config);
|
});
|
|
}
|
addRepeaterListeners(el) {
|
el.addEventListener('click', this.repeaterClick);
|
}
|
removeRepeaterListeners(el) {
|
el.removeEventListener('click', this.repeaterClick);
|
}
|
handleRepeaterClick(e) {
|
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);
|
}
|
}
|
addRepeaterRow(repeater) {
|
repeater.append(this.templates.create(repeater.dataset.repeaterId));
|
this.a11y.announce('Row added');
|
}
|
removeRepeaterRow(row) {
|
let repeater = row.closest('[data-repeater-id]');
|
row.remove();
|
this.reindexList(repeater);
|
this.a11y.announce('Row removed');
|
}
|
checkForTagLists(form) {
|
form.querySelectorAll(this.selectors.tagList.tagList)?.forEach(field=> {
|
let config = {
|
id: field.querySelector('template').className??window.generateID('tagList'),
|
ui: window.uiFromSelectors(this.selectors.tagList, field),
|
element: field,
|
form: form.dataset.formId,
|
format: field.dataset.tagFormat??'first_field'
|
};
|
if (!config.ui.input || !config.ui.add || !config.ui.items) return;
|
|
field.dataset.tagListId = config.id;
|
|
let template = field.querySelector('template');
|
this.templates.define(
|
template.className,
|
{
|
refs: {
|
label: this.selectors.tagList.label,
|
},
|
manyRefs: {
|
inputs: this.inputSelectors,
|
},
|
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}:`)
|
});
|
|
if (refs.label) {
|
refs.label.textContent = data.label;
|
}
|
}
|
},
|
);
|
|
this.tagLists.set(config.id, config);
|
this.addTagListListeners(field);
|
});
|
|
}
|
addTagListListeners(el) {
|
el.addEventListener('click', this.tagListClick);
|
el.addEventListener('keypress', this.tagListInput, {passive: true})
|
}
|
removeTagListListeners(el) {
|
el.removeEventListener('click', this.tagListClick);
|
el.removeEventListener('keypress', this.tagListInput)
|
}
|
|
handleTagListClick(e) {
|
if (e.target.matches(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));
|
}
|
}
|
addTagListItem(tagList) {
|
let config = this.tagLists.get(tagList.dataset.tagListId);
|
if (!config) return;
|
|
let data = {};
|
let hasValue = false;
|
|
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;
|
|
//clear values and validation
|
if (['checkbox', 'radio'].includes(input.type)) {
|
input.checked = false;
|
} else {
|
input.value = '';
|
}
|
this.clearValidation(input);
|
}
|
|
if (!hasValue) {
|
this.a11y.announce('Please fill in at least one field');
|
config.ui.inputs[0].focus();
|
return;
|
}
|
|
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;
|
}
|
|
let newItem = this.templates.create(tagList.dataset.tagListId, {
|
label: label
|
});
|
|
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');
|
}
|
removeTagListItem(tag) {
|
let tagList = tag.closest('[data-tag-list-id]');
|
tag.remove();
|
this.reindexList(tagList);
|
this.a11y.announce('Item removed');
|
}
|
handleTagListInput(e) {
|
let target = e.target;
|
let field = target.closest('[data-tag-list-id]');
|
if (!field) return;
|
let config = this.tagLists.get(field.dataset.tagListId);
|
if (!config) return;
|
|
if (e.key === 'Enter') {
|
if (target === config.ui.inputs[config.ui.inputs.length - 1]) {
|
e.preventDefault();
|
this.addTagListItem(target.closest('[data-tag-list-id]'));
|
} else {
|
e.preventDefault();
|
let index = config.ui.inputs.indexOf(target);
|
config.ui.inputs[index+1].focus();
|
}
|
}
|
|
}
|
|
checkForConditionalFields(form) {
|
form.querySelectorAll(this.selectors.dependsOn).forEach( field => {
|
const dependsOn = field.dataset.dependsOn;
|
const requiredValue = field.dataset.dependsValue;
|
const operator = field.dataset.dependsOperatior??'==';
|
|
if (!this.dependencies.has(dependsOn)) {
|
let element = document.querySelector(`[field="${dependsOn}"]`);
|
if (element) {
|
this.dependencies.set(dependsOn, {
|
element: element,
|
items: []
|
});
|
}
|
}
|
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);
|
});
|
}
|
checkFieldDependency(dependentField, controlFieldName) {
|
const controlField = this.dependencies.get(controlFieldName);
|
if (!controlField) return;
|
|
const controlValue = this.getFieldValue(controlField.element);
|
const shouldShow = this.evaluateCondition(
|
controlValue,
|
dependentField.requiredValue,
|
dependentField.operator
|
);
|
|
this.toggleFieldVisibility(dependentField.field, shouldShow);
|
}
|
evaluateCondition(value, requiredValue, operator) {
|
const fieldStr = String(value || '');
|
const requiredStr = String(requiredValue || '');
|
|
switch (operator) {
|
case '==': return fieldStr === requiredStr;
|
case '!=': return fieldStr !== requiredStr;
|
case '>': return parseFloat(fieldStr) > parseFloat(requiredStr);
|
case '<': return parseFloat(fieldStr) < parseFloat(requiredStr);
|
case '>=': return parseFloat(fieldStr) >= parseFloat(requiredStr);
|
case '<=': return parseFloat(fieldStr) <= parseFloat(requiredStr);
|
case 'contains': return fieldStr.includes(requiredStr);
|
case 'empty': return fieldStr === '';
|
case 'not_empty': return fieldStr !== '';
|
default: return fieldStr === requiredStr;
|
}
|
}
|
toggleFieldVisibility(field, show) {
|
const wrapper = field.closest('.field, fieldset');
|
if (!wrapper) return;
|
|
wrapper.hidden = !show;
|
wrapper.querySelectorAll('input, select, textarea').forEach(control => {
|
control.disabled = !show;
|
if (!show && control.hasAttribute('required')) {
|
control.dataset.wasRequired = 'true';
|
control.removeAttribute('required');
|
} else if (show && control.dataset.wasRequired === 'true') {
|
control.setAttribute('required', '');
|
delete control.dataset.wasRequired;
|
}
|
});
|
}
|
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);
|
|
this.addCharacterLimitListeners(input);
|
});
|
|
}
|
addCharacterLimitListeners(input) {
|
input.addEventListener('input', this.countUpdaters, {passive: true});
|
}
|
removeCharacterLimitListeners(input) {
|
input.removeEventListener('input', this.countUpdaters, {passive: true});
|
}
|
updateCount(e) {
|
let target = e.target;
|
let config = this.charLimits.get(target.dataset.charLimitId);
|
if (!config) return;
|
let length = target.value.length;
|
let limit = target.dataset.limit;
|
if (config.ui.current) {
|
config.ui.current.textContent = length;
|
config.ui.current.classList.toggle('exceeded', length >= limit);
|
}
|
if (length > limit) {
|
target.value = target.value.slice(0, limit);
|
}
|
}
|
checkForImageUploads(form, config) {
|
window.jvbUploads.scanFields(form, config.autoUpload);
|
}
|
|
checkForTabs(form, config) {
|
if (window.jvbTabs && form.querySelector('nav.tabs')) {
|
config.tabs = window.jvbTabs.registerTab(form, {
|
preCheck: (section, tabConfig) => {
|
return this.validateStep(section, config);
|
}
|
});
|
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.buttons = Array.from(form.querySelectorAll(this.selectors.tabs.buttons));
|
|
config.unsubscribeTabs = window.jvbTabs.subscribe((event, data) => {
|
if (event === 'tab-switched') {
|
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;
|
|
window.showProgress(
|
config.ui.tabs.progress,
|
step,
|
total
|
);
|
}
|
}
|
});
|
this.forms.set(config.id, config);
|
}
|
}
|
validateStep(section, config) {
|
const formId = section.closest('[data-form-id]')?.dataset.formId;
|
if (!formId) return true;
|
|
const form = this.forms.get(formId);
|
if (!form) return true;
|
|
const inputs = Array.from(this.inputs.values())
|
.filter(item =>
|
item &&
|
item.form === formId &&
|
item.section === section.dataset.tab &&
|
!item.element.closest('[hidden]')
|
);
|
|
return inputs.every(item => this.validateField(item.element) === true);
|
}
|
checkForSelectors(form) {
|
if (window.jvbSelector) window.jvbSelector.scanExistingFields(form);
|
}
|
/**
|
* Mainly for repeaters or taglist
|
* @param {HTMLElement} container
|
*/
|
reindexList(container) {
|
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}`
|
);
|
}
|
});
|
});
|
|
//schedule save
|
}
|
/**********************************************************************
|
VALIDATION
|
**********************************************************************/
|
//text, email, url, tel, date, time, datetime, number
|
//select, checkbox, radio, true_false
|
//textarea
|
//repeater: subfields validation; no submission until all required are entered
|
//tag fields: similar to repeater; each separate field is its own hidden field
|
//upload: comma separated ints
|
//selector: comma separated ints
|
//location: hidden inputs for address, lat, lng, street, city, province, postal_code, country
|
|
clearValidation(input) {
|
let field = this.getField(input);
|
if (!field) return;
|
let item = this.getItem(input);
|
if (!item) return;
|
|
field.classList.remove('has-error', 'has-success');
|
|
if (item.ui.success) item.ui.success.hidden = true;
|
if (item.ui.error) item.ui.error.hidden = true;
|
if (item.ui.message) {
|
item.ui.message.hidden = true;
|
item.ui.message.textContent = '';
|
}
|
}
|
|
showError(input, message = 'Invalid field') {
|
let field = this.getField(input);
|
if (!field) return;
|
let item = this.getItem(input);
|
if (!item) return;
|
|
field.classList.remove('has-success');
|
field.classList.add('has-error');
|
|
if (item.ui.success) item.ui.success.hidden = true;
|
if (item.ui.error) item.ui.error.hidden = true;
|
if (item.ui.message) {
|
item.ui.message.hidden = false;
|
item.ui.message.textContent = message;
|
}
|
}
|
|
showSuccess(input, message = '') {
|
let field = this.getField(input);
|
if (!field) return;
|
let item = this.getItem(input);
|
if (!item) return;
|
|
field.classList.remove('has-error');
|
field.classList.add('has-success');
|
|
if (item.ui.success) item.ui.success.hidden = false;
|
if (item.ui.error) item.ui.error.hidden = true;
|
|
if (item.ui.message) {
|
item.ui.message.hidden = message=== '';
|
item.ui.message.textContent = message;
|
}
|
}
|
|
/**********************************************************************
|
STATUS
|
**********************************************************************/
|
showFormStatus(formId, status, message ='') {
|
let form = this.forms.get(formId);
|
if (!form || !form.options.showStatus || !form.ui?.status?.status) return;
|
if (form.status === status) return;
|
|
form.ui.status.status.hidden = false;
|
form.ui.status.status.classList.toggle('loading', ['uploading', 'saving'].includes(status));
|
|
form.ui.status.message.textContent = message === '' ? this.getDefaultMessage(status) : message;
|
|
form.ui.status.icon.className = 'icon icon-'+this.getDefaultIcon(status);
|
setTimeout(()=> form.ui.status.status.hidden = true, (status === 'submitted') ? 3000 : 10000);
|
}
|
getDefaultMessage(status) {
|
const messages = {
|
'saving': 'Saving changes...',
|
'autosaved': 'Changes saved locally. Submit form to send to server.',
|
'uploading': 'Uploading your form to server',
|
'submitted': 'Successfully sent to server',
|
'pending': 'Unsaved changes',
|
'restored': 'Welcome back! We\'ve restored your previous entry.',
|
'error': 'Failed to save changes. Refresh and try again?',
|
'offline': 'Changes will be saved when online'
|
};
|
return messages[status]??status;
|
}
|
getDefaultIcon(status) {
|
const icons = {
|
'autosaved': 'check-circle',
|
'submitted': 'check-circle',
|
'restored': 'history',
|
'error': 'close-circle',
|
'offline': 'cloud-slash',
|
'pending': 'exclamation-mark'
|
}
|
return icons[status]??'';
|
}
|
|
|
/**********************************************************************
|
SUMMARY
|
**********************************************************************/
|
showSummary(data) {
|
this.templates.create('formSummary', data);
|
}
|
/**********************************************************************
|
UTILITY
|
**********************************************************************/
|
getForm(element) {
|
let form = element.closest('[data-form-id]');
|
let id = form.dataset.formId;
|
if (!id) return false;
|
let config = this.forms.get(id);
|
if (!config) return false;
|
return config;
|
}
|
getField(element) {
|
return element.closest('[data-field]');
|
}
|
getFieldType(element) {
|
let field = this.getField(element);
|
if (!field) return;
|
return field.dataset.fieldType;
|
}
|
getFieldValue(element) {
|
let type = this.getFieldType(element);
|
let conf = this.getItem(element);
|
let fieldName = conf.field?.dataset.field??false;
|
if (!fieldName) return false;
|
|
switch (type) {
|
case 'repeater':
|
return this.getRepeaterValue(element, conf);
|
|
case 'tag-list':
|
return this.getTagListValue(element, conf);
|
|
case 'group':
|
//Do we actually need anything here? I think each subfield just
|
break;
|
|
case 'location':
|
return this.getLocationValue(element, conf);
|
|
case 'selector':
|
case 'upload':
|
return this.getHiddenInputValue(element, conf, fieldName);
|
|
case 'true-false':
|
return element.value === '1'||element.value === 'on'||element.value ==='true';
|
|
default:
|
return element.value;
|
}
|
}
|
isEmptyValue(value) {
|
if (value === null || value === undefined || value === '') return true;
|
if (Array.isArray(value) && value.length === 0) return true;
|
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);
|
}
|
let value = [];
|
Array.from(conf.container.children).forEach(row => {
|
let rowData = {};
|
row.querySelectorAll('[data-field]').forEach(field => {
|
rowData[field.dataset.field] = this.getFieldValue(field);
|
});
|
value.push(rowData);
|
});
|
return value;
|
}
|
getTagListValue(element, conf) {
|
if (!conf.container) {
|
conf.container = conf.field?.querySelector('.tag-items');
|
this.saveItem(conf);
|
}
|
let value = [];
|
Array.from(conf.container.children).forEach(item => {
|
let inputs = item.querySelectorAll('input[type="hidden"]');
|
let fieldData = {};
|
inputs.forEach(input => {
|
fieldData[input.dataset.field] = input.value;
|
});
|
value.push(fieldData);
|
});
|
return value;
|
}
|
getLocationValue(element, conf) {
|
if(!conf.values){
|
conf.values = Array.from(conf.field?.querySelectorAll('[data-location-field]'));
|
this.saveItem(conf);
|
}
|
let value = {};
|
conf.values.forEach(input => {
|
value[input.dataset.locationField] = input.value;
|
});
|
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;
|
}
|
|
getItem(element, formId = null) {
|
const hasID = Object.hasOwn(element.dataset, 'ref');
|
let id = (hasID) ? element.dataset.ref : window.generateID('input');
|
if (!hasID) element.dataset.ref = id;
|
|
//check if we have it already
|
if (!this.inputs.has(id)) {
|
if (!formId) {
|
formId = element.closest('[data-form-id]')?.dataset.formId??false;
|
}
|
let field = this.getField(element);
|
|
this.inputs.set(id, {
|
id: id,
|
element: element,
|
form: formId,
|
field: field,
|
section: element.closest('[data-tab]')?.dataset.tab ?? false,
|
ui: window.uiFromSelectors(this.selectors.fields, field)
|
});
|
}
|
|
return this.inputs.get(id);
|
}
|
saveItem(config) {
|
this.inputs.set(config.id, config);
|
}
|
/**********************************************************************
|
Cleanup
|
**********************************************************************/
|
destroy() {
|
if (this.forms.size > 0) {
|
Array.from(this.forms.values()).forEach(form => {
|
this.removeFormListeners(form);
|
});
|
this.forms.clear();
|
}
|
if (this.repeaters.size > 0) {
|
Array.from(this.repeaters.values()).forEach(repeater => {
|
this.removeRepeaterListeners(repeater.element);
|
repeater.sortable?.destroy();
|
});
|
this.repeaters.clear();
|
}
|
if (this.quantityFields.size > 0) {
|
Array.from(this.quantityFields.values()).forEach(num => {
|
this.removeQuantityListeners(num.element);
|
});
|
this.quantityFields.clear();
|
}
|
if (this.tagLists.size > 0) {
|
Array.from(this.tagLists.values()).forEach(tagList => {
|
this.removeTagListListeners(tagList.element);
|
});
|
this.tagLists.clear();
|
}
|
if(this.charLimits.size > 0) {
|
Array.from(this.charLimits.values()).forEach(charLimit => {
|
charLimit.removeEventListener('input', this.countUpdaters);
|
})
|
}
|
this.inputs.clear();
|
this.forms.clear();
|
this.charLimits.clear();
|
|
}
|
}
|
document.addEventListener('DOMContentLoaded', async function () {
|
window.auth.subscribe(event => {
|
if (event === 'auth-loaded') {
|
window.jvbForm = new FormController();
|
}
|
});
|
});
|