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.locations = 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.hasUploads = false; this.summaryTemplate = false; this.init(); } init() { this.templates = window.jvbTemplates; this.defineSummaryTemplate(); this.initElements(); 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'; 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', }, restore: { container: '.restore-form', restore: '[data-action="restore"]', clear: '[data-action="clear"]', } }, inputs: this.inputSelectors, //querySelectorAll fields: { field: '.field', //querySelectorAll label: 'label', success: '.success', error: '.error', message: '.validation-message', }, repeater: { repeater: '.repeater', //querySelectorAll row: '.repeater-row', handle: '.drag-handle', 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: '.row', add: '.add-tag', remove: '.remove-tag', label: '.tag-label', items: '.tag-items', item: '.tag-item', inputs: this.inputSelectors, //querySelectorAll value: 'input[type="hidden"]' //querySelectorAll }, location: { location: '[data-field-type="location"]', //querySelectorAll search: '[data-location-search]', results: '.search-results', message: '.message', preview: '.location-preview', fields: 'fieldset.location input', }, tag: { label: '.tag-label' }, number: { number: '.field div.quantity', increase: 'button.increase', decrease: 'button.decrease', input: 'input[type="number"]' }, limits: { hasLimit: '[data-maxlength]', 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-ready') { 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.delete(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; } 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 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 ]; } } } if (uploads.length > 0) { if (restore) { await window.jvbUploads.restoreUploads(uploads); } else { await window.jvbUploads.clearUploads(uploads); } } } 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.getFieldCheckedValue(input); if (!value && !input.required) { return { isValid: true, message: '' }; } if (input.required) { if (input.type === 'checkbox') { if (!input.checked) { return { isValid: false, message: 'This field is required' }; } } else if (input.type === 'radio') { const radioGroup = document.querySelectorAll(`input[name="${input.name}"]`); const anyChecked = Array.from(radioGroup).some(r => r.checked); if (!anyChecked) { return { isValid: false, message: 'Please select an option' }; } } else if (!value) { return { isValid: false, message: 'This field is required' }; } } if(input.checkValidity && !input.checkValidity()){ 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 && validator.pattern && !validator.pattern.test(value)) { return {isValid: false, message: validator.message}; } if (validator && 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; case 'next-step': e.preventDefault(); form.tabs.ui.buttons.forEach((btn, index) => { if (btn.dataset.tab === form.tabs.activeTab) { let next = form.tabs.ui.buttons[index + 1]??false; if (next) { window.jvbTabs.switchTab(next.dataset.tab, window.jvbTabs.getConfig(next)); } } }); break; case 'prev-step': e.preventDefault(); form.tabs.ui.buttons.forEach((btn, index) => { if (btn.dataset.tab === form.tabs.activeTab) { let prev = form.tabs.ui.buttons[index - 1]??false; if (prev) { window.jvbTabs.switchTab(prev.dataset.tab, window.jvbTabs.getConfig(prev)); } } }); break; } } } handleChange(e) { if (e.target.closest('[data-ignore]') || this.isRestoring) return; let field = this.getField(e.target); // Check if this input lives inside a collection field const collectionField = e.target.closest('[data-field-type="repeater"], [data-field-type="tag-list"]'); if (collectionField) { // Dependencies still need checking if (this.dependencies.has(field.dataset.field)) { let dependency = this.dependencies.get(field.dataset.field); dependency.forEach(item => { this.checkFieldDependency(item, field.dataset.field); }); } const collectionName = collectionField.dataset.field; this.maybeUpdateCollectionDisplay(field); window.debouncer.schedule( `collection:${collectionName}`, () => this.updateCollectionField(collectionField), 150 ); return; } //Dependencies if (this.dependencies.has(field.dataset.field)) { let dependency = this.dependencies.get(field.dataset.field); dependency.forEach(item => { this.checkFieldDependency(item, field.dataset.field); }); } let form = this.getForm(e.target); console.log(this.getFieldValue(e.target), 'value'); 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 inside a collection, update the whole collection instead const collectionField = e.target.closest('[data-field-type="repeater"], [data-field-type="tag-list"]'); if (collectionField) { this.updateCollectionField(collectionField); return; } this.updateItem(fieldName, this.getFieldValue(e.target), form); } handleInput(e){ if (e.target.closest('[data-ignore]') || this.isRestoring) return; let form = this.getForm(e.target); if (!form) return; let field = this.getField(e.target); if (!field) return; const input = e.target; // Capture reference const fieldName = field.dataset.field; // Show pending status regardless of cache this.showFormStatus(form.id, 'pending'); // Debounce validation window.debouncer.schedule( `form:${form.id}:validate:${fieldName}`, () => this.validateField(input), 500 ); } async handleSubmit(e) { let form = this.getForm(e.target); if (!form) return; if (this.subscribers.size > 0) { e.preventDefault(); if (form.options.cache) { this.cancelBackup(); await this.backup(); const storedData = await this.store.get(form.id); this.notify('form-submit', { config: form, data: storedData.changes }); } else { this.notify('form-submit', { config: form, data: this.changes.get(form.id)?.changes ?? {}, }); } } if (form.options.endpoint && !form.options.handled) { e.preventDefault(); console.log('Submitting form...', this.changes.get(form.id)?.changes ?? {}); await this.handleServerSave(form, this.changes.get(form.id)?.changes ?? {}); } if (form.options.showSummary) { const storedData = await this.store.get(form.id); this.showSummary({config: form, changes: storedData?.changes}); } } /** * Updates the item, schedules caching if * @param name * @param value * @param form */ updateItem(name, value, form) { console.log('Updating field'); console.log(name,'name'); console.log(value,'value'); if (value === undefined) return; 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); //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; } if (form.tabs) { this.updateStepProgress(form); } this.changes.set(form.id, changes); if (form.options.cache) { this.scheduleBackup(); } if (form.options.endpoint && !form.options.handled) { this.scheduleServerSave(form, changes); } } updateStepProgress(form) { console.log('First check in updateStepProgress'); if (!form.tabs) return; let currentStep = this.getCurrentStep(form); console.log('Current step: ', currentStep); if (!currentStep) return; //The progress root will be 1 less than whatever the current step is currentStep = currentStep - 1; let section = this.getCurrentSection(form); console.log('Current section: ', section); if (!section) return; let total = 0; let filled = 0; section.childNodes.forEach(f => { console.log('Child node: ', f); if (f.classList.contains('field')) { total++; let value = this.getFieldValue(f.querySelector(this.inputSelectors)); console.log('Value', value); if (value.length > 0) { filled++; } } }); //Shouldn't happen, but simplifies things if it does if (total === 0) return; let current = currentStep + (filled / total); let totalSteps = form.ui.tabs.sections.length; window.showProgress( form.ui.tabs.progress, current, totalSteps, `Step ${currentStep +1} of ${totalSteps}` ); } getCurrentStep(form) { let step = null; form.tabs.ui.buttons.forEach(btn => { if (btn.dataset.tab === form.tabs.activeTab) { step = btn.dataset.step; } }); return step; } getCurrentSection(form) { console.log('Getting current section: ', form.tabs.ui.sections); let section = null; form.tabs.ui.sections.forEach(s => { if (s.dataset.tab === form.tabs.activeTab) { section = s; } }); return section; } scheduleBackup() { window.debouncer.schedule( `form_changes`, async () => { if (this.changes.size > 0) { await this.backup(); } }, 2000 ); } cancelBackup() { window.debouncer.cancel('form_changes'); } async backup() { // Merge with existing stored data const toSave = new Map(); for (let [formId, newData] of this.changes.entries()) { const stored = await this.store.get(formId); if (stored) { // Merge changes toSave.set(formId, { ...stored, ...newData, changes: { ...stored.changes, ...newData.changes }, timestamp: Date.now() }); } else { toSave.set(formId, newData); } } await this.store.saveMany(toSave); for (let formId of this.changes.keys()) { this.showFormStatus(formId, 'autosaved'); } this.changes.clear(); } saveCache(formId) { if (!this.changes.has(formId)) return; let changes = this.changes.get(formId); if (changes.size === 0) return; this.store.save(changes).then(()=>{}); this.changes.delete(formId); } scheduleServerSave(form, changes){ window.debouncer.schedule( `form_${form.id}_server_save`, async () => { await this.handleServerSave(form, changes); }, 1500 ); } cancelServerSave(formId) { window.debouncer.cancel(`form_${formId}_server_save`); } async handleServerSave(form, changes) { this.cancelServerSave(form.id); if (form.useQueue && window.jvbQueue) { // changes.user = window.auth.getUser(); window.jvbQueue.addToQueue({ endpoint: form.options.endpoint, data: changes, headers: form.options.headers??{}, }); } else { try { let response = await window.auth.fetch(jvbSettings.api+form.options.endpoint, { body: changes, headers: form.options.headers??{}, method: 'POST', }); if (!response.ok) { let errorDetails; try { // Attempt to parse the server's error payload as JSON errorDetails = await response.json(); } catch { // Fallback if the error response isn't JSON (e.g., HTML error page) errorDetails = { message: response.statusText || 'Unknown HTTP error' }; } this.notify('form-error', { config: form, data: errorDetails }); return; } let result = await response.json(); this.notify('form-success', { config: form, result: result }); } catch (error) { if (error.status) { this.notify('form-error', { config: form, data: error }); // This is an HTTP error thrown manually (e.g., 404, 500) } else if (error instanceof SyntaxError) { this.notify('form-error', { config: form, data: { status: 'error', success: false, message: 'Something went wrong' } }); // This happens if response.json() fails on a successful 200 OK response console.error('Data Error: Server returned invalid JSON format.'); } else { this.notify('form-error', { config: form, data: { status: 'network_error', message: 'Network error: could not connect to the server' } }); } } } } /** * Register a form for handling * @param {HTMLElement} form * @param {object} options */ registerForm(form, options) { options = { autoUpload: false, imageMeta: true, delay: 1500, useQueue: true, endpoint: Object.hasOwn(form.dataset, 'save') ? form.dataset.save: '', showStatus: true, showSummary: false, handled: Object.hasOwn(form.dataset, 'handled'), cache: true, ignore: [], ... 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_'); } if (Object.hasOwn(form.dataset, 'action')) { options.headers = window.auth.getHeader(form.dataset.action); } const formId = form.dataset.formId; this.addFormListeners(form); const config = { element: form, id: formId, status: '', 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; 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 = dependency.filter(item => item.form !== formId); // Remove the dependency entry entirely if no items left if (dependency.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, location: this.locations }; 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; case 'location': this.removeLocationListeners(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.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(); } } ); } initializeFields(container, config = null) { const fieldHandlers = { '[data-editor]': () => this.checkForQuill(container,config), '[data-field-type="location"]': () => this.checkForLocationFields(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) }; for (const [selector, handler] of Object.entries(fieldHandlers)) { if (container.querySelector(selector)) { handler(); } } 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); }); } }, ); if (window.Sortable) { config.sortable = new Sortable(config.ui.items, { animation: 150, draggable: this.selectors.repeater.row, handle: this.selectors.repeater.handle, ghostClass: 'repeater-row-ghost', chosenClass: 'repeater-row-chosen', dragClass: 'dragging', onEnd: () => { this.reindexList(config.ui.items); } }); } 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.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.reindexList(config.ui.items); this.a11y.announce('Row added'); } removeRepeaterRow(row) { let repeater = row.closest('[data-repeater-id]'); row.remove(); let items = this.repeaters.get(repeater); if (items) { this.reindexList(items.ui.items); } 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(); } } } checkForLocationFields(form, config) { form.querySelectorAll(this.selectors.location.location).forEach(field => { let ui = window.uiFromSelectors(this.selectors.location, field); if (!ui.search) return; ui.fields = {}; field.querySelectorAll(this.selectors.location.fields).forEach(f => { ui.fields[f.dataset.fieldName] = f; }); let id = window.generateID('location'); field.dataset.locationId = id; this.locations.set(id, { id: id, form: config?.id, element: field, ui: ui, results: [] }); this.addLocationListeners(field); }); } addLocationListeners(el) { el.addEventListener('input', this.locationInputHandler ??= this.handleLocationInput.bind(this)); el.addEventListener('click', this.locationClickHandler ??= this.handleLocationClick.bind(this)); } removeLocationListeners(el) { el.removeEventListener('input', this.locationInputHandler); el.removeEventListener('click', this.locationClickHandler); } handleLocationInput(e) { if (!e.target.matches(this.selectors.location.search)) return; let field = e.target.closest('[data-location-id]'); if (!field) return; let conf = this.locations.get(field.dataset.locationId); if (!conf) return; let query = e.target.value.trim(); if (query.length < 3) { conf.ui.results.replaceChildren(); return; } window.debouncer.schedule( `location:${conf.id}`, () => this.searchLocation(conf, query), 400 ); } async searchLocation(conf, query) { conf.ui.message.hidden = false; try { let response = await window.auth.fetch( `${jvbSettings.api}geocode?q=${encodeURIComponent(query)}`, { method: 'GET' } ); conf.results = response.ok ? await response.json() : []; } catch (err) { console.error('Geocode error:', err); conf.results = []; } conf.ui.message.hidden = true; this.renderLocationResults(conf); } renderLocationResults(conf) { conf.ui.results.replaceChildren(); if (conf.results.length === 0) { conf.ui.results.innerHTML = '