| | |
| | | |
| | | this.isRestoring = false; |
| | | this.hasListeners = false; |
| | | this.hasUploads = false; |
| | | this.summaryTemplate = false; |
| | | |
| | | this.init(); |
| | |
| | | this.initListeners(); |
| | | this.initStore(); |
| | | this.initValidators(); |
| | | this.initUploadSubscription(); |
| | | } |
| | | |
| | | initUploadSubscription() { |
| | | window.jvbUploads.subscribe((event, data) => { |
| | | if (!this.hasUploads) return; |
| | | if (event === 'upload-received') { |
| | | let form = this.getForm(data.field); |
| | | if (form) { |
| | | this.updateItem(`${data.field.dataset.field}_tempUpload`, data.id, form); |
| | | } |
| | | |
| | | } |
| | | }); |
| | | } |
| | | initElements() { |
| | | this.inputSelectors = 'input, textarea, select'; |
| | |
| | | status: '.fstatus', |
| | | message: '.fstatus .message', |
| | | icon: '.fstatus .icon', |
| | | actions: '.fstatus .actions' |
| | | actions: '.fstatus .actions', |
| | | }, |
| | | restore: { |
| | | container: '.restore-form', |
| | | restore: '[data-action="restore"]', |
| | | clear: '[data-action="clear"]', |
| | | } |
| | | }, |
| | | inputs: this.inputSelectors, //querySelectorAll |
| | |
| | | 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); |
| | | } |
| | | 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', |
| | |
| | | } |
| | | }); |
| | | } |
| | | showPendingNotification(formId, changes) { |
| | | let form = this.forms.get(formId); |
| | | 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; |
| | | } |
| | | |
| | | form.ui.restore.container.hidden = false; |
| | | const handleRestore = async (changes, element) => { |
| | | this.isRestoring = true; |
| | | let theChanges = {['fields']: changes}; |
| | | await this.checkStoredUploads(changes, element); |
| | | this.populate.populate(element, theChanges); |
| | | this.a11y.announce('Previous changes restored'); |
| | | this.isRestoring = false; |
| | | form.ui.restore.container.remove(); |
| | | }; |
| | | const clearRestore = async (formId) => { |
| | | await this.checkStoredUploads(changes, element, false); |
| | | await this.store.delete(formId); |
| | | this.a11y.announce('Previous changes discarded'); |
| | | form.ui.restore.container.remove(); |
| | | }; |
| | | form.ui.restore.restore.addEventListener('click', () => handleRestore(changes, element)); |
| | | form.ui.restore.clear.addEventListener('click', async () => clearRestore(formId)); |
| | | } |
| | | async checkStoredUploads(changes, element, restore = true) { |
| | | let form = this.forms.get(element.dataset.formId); |
| | | if (!form) return; |
| | | let element = form.element; |
| | | if (!element) { |
| | | console.warn(`Form element not found for: ${formId}`); |
| | | return; |
| | | let uploads = []; |
| | | for (let [key, value] of Object.entries(changes)) { |
| | | if (key.includes('_tempUpload')) { |
| | | let field = key.replace('_tempUpload', ''); |
| | | |
| | | if (Object.hasOwn(form.ui.uploads, field)) { |
| | | uploads = [ |
| | | ... uploads, |
| | | ... value |
| | | ]; |
| | | } |
| | | } |
| | | } |
| | | |
| | | 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" type="button" data-form-id="${formId}">Restore</button> |
| | | <button class="discard" type="button" data-form-id="${formId}">Discard</button>`; |
| | | if (uploads.length > 0) { |
| | | if (restore) { |
| | | await window.jvbUploads.restoreUploads(uploads); |
| | | } else { |
| | | await window.jvbUploads.clearUploads(uploads); |
| | | } |
| | | |
| | | element.insertBefore(notification, form.ui.status.status); |
| | | |
| | | notification.querySelector('.restore').addEventListener('click', async () => { |
| | | this.isRestoring = true; |
| | | |
| | | let theChanges = {['fields']: changes}; |
| | | this.populate.populate(element, theChanges); |
| | | this.a11y.announce('Previous changes restored'); |
| | | |
| | | this.isRestoring = false; |
| | | notification.remove(); |
| | | }); |
| | | |
| | | notification.querySelector('.discard').addEventListener('click', async () => { |
| | | await this.store.delete(formId); |
| | | this.a11y.announce('Previous changes discarded'); |
| | | notification.remove(); |
| | | }); |
| | | |
| | | } |
| | | } |
| | | initValidators() { |
| | | this.validators = { |
| | |
| | | // Dependencies still need checking |
| | | if (this.dependencies.has(field.dataset.field)) { |
| | | let dependency = this.dependencies.get(field.dataset.field); |
| | | dependency.items.forEach(item => { |
| | | dependency.forEach(item => { |
| | | this.checkFieldDependency(item, field.dataset.field); |
| | | }); |
| | | } |
| | |
| | | //Dependencies |
| | | if (this.dependencies.has(field.dataset.field)) { |
| | | let dependency = this.dependencies.get(field.dataset.field); |
| | | dependency.items.forEach(item => { |
| | | dependency.forEach(item => { |
| | | this.checkFieldDependency(item, field.dataset.field); |
| | | }); |
| | | } |
| | |
| | | * @param form |
| | | */ |
| | | updateItem(name, value, form) { |
| | | if (value === undefined) return; |
| | | if (!this.changes.has(form.id)) { |
| | | this.changes.set(form.id, { |
| | | id: form.id, |
| | |
| | | }); |
| | | } |
| | | let changes = this.changes.get(form.id); |
| | | changes.changes[name] = value; |
| | | //If it is temporary uploads, we need to store them all |
| | | if (name.includes('_tempUpload')) { |
| | | if (!Object.hasOwn(changes.changes, name)) { |
| | | changes.changes[name] = []; |
| | | } |
| | | changes.changes[name].push(value); |
| | | } else { |
| | | changes.changes[name] = value; |
| | | } |
| | | |
| | | this.changes.set(form.id, changes); |
| | | if (form.options.cache) { |
| | | this.scheduleBackup(); |
| | |
| | | * @param {object} options |
| | | */ |
| | | registerForm(form, options) { |
| | | options = { |
| | | autoUpload: false, |
| | | imageMeta: true, |
| | | delay: 1500, |
| | | endpoint: Object.hasOwn(form.dataset, 'save') ? form.dataset.save: '', |
| | | showStatus: true, |
| | | showSummary: false, |
| | | cache: true, |
| | | ignore: [], |
| | | ... options |
| | | }; |
| | | //Bail if form already registered |
| | | if (Object.hasOwn(form.dataset, 'formId') && this.forms.has(form.dataset.formId)) return; |
| | | |
| | |
| | | element: form, |
| | | id: formId, |
| | | status: '', |
| | | options: { |
| | | autoUpload: options.autoUpload??false, |
| | | imageMeta: options.imageMeta??true, |
| | | delay: options.delay??1500, |
| | | endpoint: options.save??form.dataset.save??'', |
| | | showStatus: options.showStatus??true, |
| | | showSummary: options.showSummary??false, |
| | | cache: options.cache??true, |
| | | ignore: options.ignore??[] |
| | | }, |
| | | options: options, |
| | | ui: window.uiFromSelectors(this.selectors.forms, form) |
| | | }; |
| | | |
| | | config.ui.fields = {}; |
| | | form.querySelectorAll('[data-field]').forEach((field) => { |
| | | config.ui.fields[field.dataset.field] = field; |
| | | }); |
| | | |
| | | this.initializeFields(form, config); |
| | | this.forms.set(formId, config); |
| | | |
| | | return config; |
| | | } |
| | | clearForm(formId) { |
| | | const config = this.forms.get(formId); |
| | | if (!config) return; |
| | | clearForm(formId) { |
| | | const config = this.forms.get(formId); |
| | | if (!config) return; |
| | | |
| | | if (config.unsubscribeTabs) { |
| | | config.unsubscribeTabs(); |
| | | 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); |
| | | } |
| | | if(config.tabs) { |
| | | window.jvbTabs.removeTab(config.element); |
| | | } |
| | | // Clean up dependencies for this form |
| | | this.dependencies.forEach((dependency, fieldName) => { |
| | | dependency = dependency.filter(item => item.form !== formId); |
| | | |
| | | // Remove the dependency entry entirely if no items left |
| | | if (dependency.length === 0) { |
| | | this.dependencies.delete(fieldName); |
| | | } |
| | | }); |
| | | |
| | | if (config.cache && this.changes.has(formId)) this.saveCache(formId); |
| | | if (Object.hasOwn(config, 'hasQuill') && this.quillInstances.has(formId)) { |
| | | const instances = this.quillInstances.get(formId); |
| | | instances.forEach(quillInstance => { |
| | | // Disable the editor |
| | | quillInstance.disable(); |
| | | |
| | | // Cleanup items |
| | | for (let [id, input] of this.inputs.entries()) { |
| | | if (input.form === formId) { |
| | | this.inputs.delete(id); |
| | | // 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(); |
| | | } |
| | | } |
| | | // 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); |
| | | // 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(); |
| | | } |
| | | }); |
| | | |
| | | 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(); |
| | | 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; |
| | | } |
| | | |
| | | // 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(); |
| | | if (check.has(item.id)) { |
| | | check.delete(item.id); |
| | | } |
| | | }); |
| | | |
| | | 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; |
| | | } |
| | | |
| | | if (check.has(item.id)) { |
| | | 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', ...data.config.options.ignore??[]]; |
| | | |
| | | for (let [key, value] of Object.entries(data.changes)) { |
| | | if (skipFields.includes(key) || form.isEmptyValue(value)) continue; |
| | | |
| | | let input = Array.from(form.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'); |
| | | |
| | | // 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(); |
| | | |
| | | |
| | | 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); |
| | | } |
| | | 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'); |
| | | let field = refs.result.cloneNode(true); |
| | | 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],[data-upload-field]': () => this.checkForImageUploads(container, config), |
| | | 'nav.tabs': () => this.checkForTabs(container, config), |
| | | '[data-type="selector"]': () => this.checkForSelectors(container) |
| | | }; |
| | | this.removeFormListeners(config.element); |
| | | this.forms.delete(formId); |
| | | |
| | | for (const [selector, handler] of Object.entries(fieldHandlers)) { |
| | | if (container.querySelector(selector)) { |
| | | handler(); |
| | | } |
| | | } |
| | | 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', ...data.config.options.ignore??[]]; |
| | | |
| | | let inputs = Array.from(container.querySelectorAll(this.inputSelectors)) |
| | | .filter(input => !input.closest('.ql-clipboard')); |
| | | 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); |
| | | } |
| | | for (let [key, value] of Object.entries(data.changes)) { |
| | | if (skipFields.includes(key) || form.isEmptyValue(value)) continue; |
| | | |
| | | if (!this.quillInstances.has(config.id)) { |
| | | this.quillInstances.set(config.id, new Set()); |
| | | } |
| | | let input = Array.from(form.inputs.values()) |
| | | .find(temp => temp.field?.dataset.field === key); |
| | | if (!input) continue; |
| | | |
| | | 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.ui.increase.contains(e.target)) { |
| | | change++; |
| | | } else if (conf.ui.decrease.contains(e.target)) { |
| | | change--; |
| | | let entry = refs.result.cloneNode(true); |
| | | let title = entry.querySelector('h3'); |
| | | let p = entry.querySelector('p'); |
| | | |
| | | // 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(); |
| | | |
| | | |
| | | 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); |
| | | } |
| | | if (change === 0) return; |
| | | let field = this.getField(e.target); |
| | | let step = conf.ui.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.ui.input.value === '') ? 0 : parseFloat(conf.ui.input.value); |
| | | conf.ui.input.value = (value + (step * change)); |
| | | |
| | | value = parseFloat(conf.ui.input.value); |
| | | |
| | | 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.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 => { |
| | | 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, |
| | | rows: [] |
| | | }; |
| | | |
| | | if (!config.ui.add) 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, `${data.repeater.dataset.field}:${index}:`, el); |
| | | 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'); |
| | | let field = refs.result.cloneNode(true); |
| | | 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); |
| | | }); |
| | | } |
| | | }, |
| | | ); |
| | | |
| | | if (window.Sortable) { |
| | | config.sortable = new Sortable(repeater, { |
| | | handle: this.selectors.repeater.header, |
| | | animation: 150, |
| | | onEnd: () => { |
| | | this.reindexList(repeater); |
| | | el.append(entry); |
| | | } |
| | | }); |
| | | } |
| | | |
| | | repeater.dataset.repeaterId = config.id; |
| | | this.addRepeaterListeners(repeater); |
| | | this.repeaters.set(config.id, config); |
| | | }); |
| | | |
| | | refs.result?.remove(); |
| | | data.config.element.after(el); |
| | | window.fade(data.config.element, false); |
| | | } |
| | | } |
| | | 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.closest('[data-index]')); |
| | | } |
| | | } |
| | | addRepeaterRow(repeater) { |
| | | 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) { |
| | | 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; |
| | | 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],[data-upload-field]': () => this.checkForImageUploads(container, config), |
| | | 'nav.tabs': () => this.checkForTabs(container, config), |
| | | '[data-type="selector"]': () => this.checkForSelectors(container) |
| | | }; |
| | | |
| | | field.dataset.tagListId = config.id; |
| | | config.fieldName = field.dataset.field; |
| | | |
| | | 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 => { |
| | | let wrapper = input.closest('.tag-item'); |
| | | window.prefixInput(input, `${data.fieldName}:${index}:`, wrapper) |
| | | }); |
| | | |
| | | if (refs.label) { |
| | | refs.label.textContent = data.label; |
| | | } |
| | | } |
| | | }, |
| | | ); |
| | | 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); |
| | | }); |
| | | |
| | | for (const [selector, handler] of Object.entries(fieldHandlers)) { |
| | | if (container.querySelector(selector)) { |
| | | handler(); |
| | | } |
| | | addTagListListeners(el) { |
| | | el.addEventListener('click', this.tagListClick); |
| | | el.addEventListener('keypress', this.tagListInput); |
| | | } |
| | | removeTagListListeners(el) { |
| | | el.removeEventListener('click', this.tagListClick); |
| | | el.removeEventListener('keypress', this.tagListInput); |
| | | } |
| | | } |
| | | |
| | | handleTagListClick(e) { |
| | | if (window.targetCheck(e,this.selectors.tagList.add)) { |
| | | this.addTagListItem(e.target.closest('[data-tag-list-id]')); |
| | | } else if (window.targetCheck(e, this.selectors.tagList.remove)) { |
| | | this.removeTagListItem(e.target.closest(this.selectors.tagList.item)); |
| | | let inputs = Array.from(container.querySelectorAll(this.inputSelectors)) |
| | | .filter(input => !input.closest('.ql-clipboard')); |
| | | 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.ui.increase.contains(e.target)) { |
| | | change++; |
| | | } else if (conf.ui.decrease.contains(e.target)) { |
| | | change--; |
| | | } |
| | | if (change === 0) return; |
| | | let field = this.getField(e.target); |
| | | let step = conf.ui.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.ui.input.value === '') ? 0 : parseFloat(conf.ui.input.value); |
| | | conf.ui.input.value = (value + (step * change)); |
| | | |
| | | value = parseFloat(conf.ui.input.value); |
| | | |
| | | 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.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 => { |
| | | 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, |
| | | rows: [] |
| | | }; |
| | | |
| | | if (!config.ui.add) 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, `${data.repeater.dataset.field}:${index}:`, el, false, true); |
| | | }); |
| | | } |
| | | } |
| | | addTagListItem(tagList) { |
| | | let config = this.tagLists.get(tagList.dataset.tagListId); |
| | | if (!config) return; |
| | | }, |
| | | ); |
| | | |
| | | let data = {}; |
| | | let hasValue = false; |
| | | let isValid = true; |
| | | |
| | | // 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); |
| | | |
| | | 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; |
| | | if (window.Sortable) { |
| | | config.sortable = new Sortable(repeater, { |
| | | handle: this.selectors.repeater.header, |
| | | animation: 150, |
| | | onEnd: () => { |
| | | this.reindexList(repeater); |
| | | } |
| | | |
| | | 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); |
| | | } |
| | | } else { |
| | | label = data[config.format]??Object.values(data)[0]; |
| | | } |
| | | break; |
| | | } |
| | | |
| | | let newItem = this.templates.create(tagList.dataset.tagListId, { |
| | | label: label, |
| | | fieldName: config.fieldName |
| | | }); |
| | | |
| | | 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] || ''; |
| | | }); |
| | | |
| | | config.ui.items.append(newItem); |
| | | |
| | | // Clear inputs AFTER success |
| | | for (let input of config.ui.inputs) { |
| | | if (['checkbox', 'radio'].includes(input.type)) { |
| | | input.checked = false; |
| | | } else { |
| | | input.value = ''; |
| | | } |
| | | 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]'); |
| | | 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.getFieldCheckedValue(controlField.element); |
| | | const shouldShow = this.evaluateCondition( |
| | | controlValue, |
| | | dependentField.requiredValue, |
| | | dependentField.operator |
| | | ); |
| | | repeater.dataset.repeaterId = config.id; |
| | | this.addRepeaterListeners(repeater); |
| | | this.repeaters.set(config.id, config); |
| | | }); |
| | | |
| | | this.toggleFieldVisibility(dependentField.field, shouldShow); |
| | | } |
| | | evaluateCondition(value, requiredValue, operator) { |
| | | const fieldStr = String(value || ''); |
| | | const requiredStr = String(requiredValue || ''); |
| | | } |
| | | 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.closest('[data-index]')); |
| | | } |
| | | } |
| | | addRepeaterRow(repeater) { |
| | | let data = {}; |
| | | data.repeater = repeater; |
| | | let config = this.repeaters.get(repeater.dataset.repeaterId); |
| | | |
| | | 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; |
| | | 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); |
| | | |
| | | 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; |
| | | let form = this.getForm(repeater); |
| | | this.initializeFields(repeater, form); |
| | | 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; |
| | | config.fieldName = field.dataset.field; |
| | | |
| | | 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 => { |
| | | let wrapper = input.closest('.tag-item'); |
| | | window.prefixInput(input, `${data.fieldName}:${index}:`, wrapper, false, true) |
| | | }); |
| | | |
| | | if (refs.label) { |
| | | refs.label.textContent = data.label; |
| | | } |
| | | }); |
| | | } |
| | | }, |
| | | ); |
| | | 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); |
| | | }); |
| | | |
| | | } |
| | | addTagListListeners(el) { |
| | | el.addEventListener('click', this.tagListClick); |
| | | el.addEventListener('keypress', this.tagListInput); |
| | | } |
| | | removeTagListListeners(el) { |
| | | el.removeEventListener('click', this.tagListClick); |
| | | el.removeEventListener('keypress', this.tagListInput); |
| | | } |
| | | |
| | | handleTagListClick(e) { |
| | | if (window.targetCheck(e,this.selectors.tagList.add)) { |
| | | this.addTagListItem(e.target.closest('[data-tag-list-id]')); |
| | | } 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; |
| | | |
| | | let data = {}; |
| | | let hasValue = false; |
| | | let isValid = true; |
| | | |
| | | // 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); |
| | | |
| | | 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); |
| | | } |
| | | } else { |
| | | label = data[config.format]??Object.values(data)[0]; |
| | | } |
| | | break; |
| | | } |
| | | |
| | | let newItem = this.templates.create(tagList.dataset.tagListId, { |
| | | label: label, |
| | | fieldName: config.fieldName |
| | | }); |
| | | |
| | | 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] || ''; |
| | | }); |
| | | |
| | | config.ui.items.append(newItem); |
| | | |
| | | // Clear inputs AFTER success |
| | | for (let input of config.ui.inputs) { |
| | | if (['checkbox', 'radio'].includes(input.type)) { |
| | | input.checked = false; |
| | | } else { |
| | | input.value = ''; |
| | | } |
| | | 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]'); |
| | | 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??'=='; |
| | | |
| | | let formData = this.forms.get(form.dataset.formId); |
| | | |
| | | if (!this.dependencies.has(dependsOn)) { |
| | | if (Object.hasOwn(formData.ui.fields, dependsOn)) { |
| | | this.dependencies.set(dependsOn, []); |
| | | } |
| | | } |
| | | let dependency = this.dependencies.get(dependsOn); |
| | | if (dependency) { |
| | | dependency.push({ |
| | | field: field, |
| | | form: form.dataset.formId, |
| | | requiredValue: requiredValue, |
| | | operator: operator |
| | | }); |
| | | this.dependencies.set(dependsOn, dependency); |
| | | } |
| | | |
| | | this.checkFieldDependency(field, dependsOn); |
| | | }); |
| | | } |
| | | checkFieldDependency(dependentField, controlFieldName) { |
| | | const form = this.getForm(dependentField); |
| | | const controlField = this.dependencies.get(controlFieldName); |
| | | if (!controlField) return; |
| | | |
| | | |
| | | const controlValue = this.getFieldValue(form.ui.fields[controlFieldName]); |
| | | const shouldShow = this.evaluateCondition( |
| | | controlValue, |
| | | dependentField.dataset.dependsValue, |
| | | dependentField.dataset.dependsOperatior |
| | | ); |
| | | |
| | | this.toggleFieldVisibility(dependentField, 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); |
| | |
| | | this.addCharacterLimitListeners(input); |
| | | }); |
| | | } |
| | | addCharacterLimitListeners(input) { |
| | | input.addEventListener('input', this.countUpdaters, {passive: true}); |
| | | 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) { |
| | | this.hasUploads = true; |
| | | window.jvbUploads.scanFields(form, config.options.autoUpload, config.options.imageMeta); |
| | | let uploads = form.querySelectorAll('[data-field-type="upload"]'); |
| | | if (uploads) { |
| | | config.ui.uploads = {}; |
| | | uploads.forEach(upload => { |
| | | config.ui.uploads[upload.dataset.field] = upload; |
| | | }); |
| | | } |
| | | } |
| | | |
| | | checkForTabs(form, config) { |
| | | if (window.jvbTabs && form.querySelector('nav.tabs')) { |
| | | config.tabs = window.jvbTabs.registerTab(form, { |
| | | preCheck: (section, tabConfig) => { |
| | | return this.validateStep(section, config); |
| | | } |
| | | 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); |
| | | }); |
| | | 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 |
| | | ); |
| | | } |
| | | } |
| | | checkForImageUploads(form, config) { |
| | | window.jvbUploads.scanFields(form, config.options.autoUpload, config.options.imageMeta); |
| | | } |
| | | }); |
| | | this.forms.set(config.id, config); |
| | | } |
| | | } |
| | | validateStep(section, config) { |
| | | const formId = section.closest('[data-form-id]')?.dataset.formId; |
| | | if (!formId) return true; |
| | | |
| | | 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)); |
| | | const form = this.forms.get(formId); |
| | | if (!form) return true; |
| | | |
| | | 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; |
| | | const inputs = Array.from(this.inputs.values()) |
| | | .filter(item => |
| | | item && |
| | | item.form === formId && |
| | | item.section === section.dataset.tab && |
| | | !item.element.closest('[hidden]') |
| | | ); |
| | | |
| | | 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); |
| | | } |
| | | 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 |
| | |
| | | window.prefixInput( |
| | | input, |
| | | `${fieldName}:${index}:`, |
| | | item // Pass the item as wrapper for label lookup |
| | | item, |
| | | false, |
| | | true |
| | | ); |
| | | }); |
| | | }); |
| | |
| | | } |
| | | /********************************************************************** |
| | | VALIDATION |
| | | **********************************************************************/ |
| | | **********************************************************************/ |
| | | //text, email, url, tel, date, time, datetime, number |
| | | //select, checkbox, radio, true_false |
| | | //textarea |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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; |
| | | 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' |
| | | } |
| | | getDefaultIcon(status) { |
| | | const icons = { |
| | | 'autosaved': 'check-circle', |
| | | 'submitted': 'check-circle', |
| | | 'restored': 'history', |
| | | 'error': 'close-circle', |
| | | 'offline': 'cloud-slash', |
| | | 'pending': 'exclamation-mark' |
| | | } |
| | | return icons[status]??''; |
| | | } |
| | | return icons[status]??''; |
| | | } |
| | | |
| | | |
| | | /********************************************************************** |
| | | SUMMARY |
| | | **********************************************************************/ |
| | | **********************************************************************/ |
| | | showSummary(data) { |
| | | let summary = this.templates.create('formSummary', data); |
| | | data.config.element.after(summary); |
| | |
| | | } |
| | | /********************************************************************** |
| | | UTILITY |
| | | **********************************************************************/ |
| | | **********************************************************************/ |
| | | getForm(element) { |
| | | let form = element.closest('[data-form-id]'); |
| | | if (!form) return false; |
| | |
| | | getFieldValue(element) { |
| | | let type = this.getFieldType(element); |
| | | let conf = this.getItem(element); |
| | | |
| | | let fieldName = conf.field?.dataset.field??false; |
| | | if (!fieldName) return false; |
| | | |
| | |
| | | return this.getTagListValue(element, conf); |
| | | |
| | | case 'group': |
| | | //Do we actually need anything here? I think each subfield just |
| | | break; |
| | | return null; |
| | | //Do we actually need anything here? I think each subfield just |
| | | |
| | | case 'location': |
| | | return this.getLocationValue(element, conf); |
| | |
| | | if (Array.isArray(value) && value.length === 0) return true; |
| | | return typeof value === 'object' && Object.keys(value).length === 0; |
| | | } |
| | | getRepeaterValue(element, 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(items.children).forEach(row => { |
| | | let rowData = {}; |
| | | row.querySelectorAll('[data-field]').forEach(field => { |
| | | if (!ignore.includes(field.dataset.field)) { |
| | | const input = this.getFieldInput(field); |
| | | if (input) { |
| | | rowData[field.dataset.field] = this.getFieldValue(input); |
| | | } |
| | | getRepeaterValue(element, 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(items.children).forEach(row => { |
| | | let rowData = {}; |
| | | row.querySelectorAll('[data-field]').forEach(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; |
| | | } |
| | | value.push(rowData); |
| | | }); |
| | | return value; |
| | | } |
| | | getFieldInput(field) { |
| | | // For quill fields, target the specific editor textarea |
| | | const quillTextarea = field.querySelector('textarea[data-editor]'); |
| | |
| | | |
| | | return field.querySelector(this.inputSelectors); |
| | | } |
| | | 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}"]`) |
| | | || conf.field?.querySelector(`input[type=hidden]`); |
| | | getTagListValue(element, conf) { |
| | | if (!conf.container) { |
| | | conf.container = conf.field?.querySelector('.tag-items'); |
| | | this.saveItem(conf); |
| | | } |
| | | return conf.value?.value ?? ''; |
| | | 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 (element.tagName !== 'INPUT' || element.type !== 'hidden'){ |
| | | element = element.querySelector('input[type="hidden"][name="'+fieldName+'"]'); |
| | | if (!element) { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | if (conf.value === undefined || conf.value !== element.value) { |
| | | conf.value = element.value; |
| | | this.saveItem(conf); |
| | | } |
| | | return conf.value; |
| | | } |
| | | |
| | | /** |
| | |
| | | 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 |
| | | // These might need special handling depending on your needs |
| | | return this.formatHiddenFieldForSummary(value, input, fieldType); |
| | | |
| | | default: |
| | |
| | | } |
| | | /********************************************************************** |
| | | Subscription |
| | | **********************************************************************/ |
| | | **********************************************************************/ |
| | | subscribe(callback) { |
| | | this.subscribers.add(callback); |
| | | return () => this.subscribers.delete(callback); |
| | |
| | | } |
| | | /********************************************************************** |
| | | Cleanup |
| | | **********************************************************************/ |
| | | **********************************************************************/ |
| | | destroy() { |
| | | if (this.forms.size > 0) { |
| | | Array.from(this.forms.values()).forEach(form => { |