| | |
| | | } |
| | | init() { |
| | | this.templates = window.jvbTemplates; |
| | | this.defineSummaryTemplate(); |
| | | this.initElements(); |
| | | this.initListeners(); |
| | | this.initStore(); |
| | |
| | | this.store = store.forms; |
| | | |
| | | this.store.subscribe((event, data)=> { |
| | | if (event === 'data-loaded') { |
| | | if (event === 'data-ready') { |
| | | let stored = this.store.getFiltered(); |
| | | |
| | | let pending = stored.filter(form=> form.src === window.location.pathname); |
| | |
| | | } |
| | | } else if (event === 'operation-status' && data.status === 'completed') { |
| | | if (data.config) { |
| | | this.store.remove(data.config.id); |
| | | this.store.delete(data.config.id); |
| | | } |
| | | } |
| | | }); |
| | |
| | | notification.className = 'pendingChanges'; |
| | | notification.innerHTML = ` |
| | | <p>We noticed unsaved changes from last time. Would you like to restore them?</p> |
| | | <button class="restore" data-form-id="${formId}">Restore</button> |
| | | <button class="discard" data-form-id="${formId}">Discard</button>`; |
| | | <button class="restore" type="button" data-form-id="${formId}">Restore</button> |
| | | <button class="discard" type="button" data-form-id="${formId}">Discard</button>`; |
| | | |
| | | element.insertBefore(notification, form.ui.status.status); |
| | | |
| | | notification.querySelector('.restore').addEventListener('click', async () => { |
| | | this.isRestoring = true; |
| | | |
| | | new this.populate(element, changes); |
| | | let theChanges = {['fields']: changes}; |
| | | this.populate.populate(element, theChanges); |
| | | this.a11y.announce('Previous changes restored'); |
| | | |
| | | this.isRestoring = false; |
| | |
| | | }); |
| | | |
| | | notification.querySelector('.discard').addEventListener('click', async () => { |
| | | await this.store.remove(formId); |
| | | this.a11y.announce('Previous changes discared'); |
| | | await this.store.delete(formId); |
| | | this.a11y.announce('Previous changes discarded'); |
| | | notification.remove(); |
| | | }); |
| | | |
| | |
| | | } |
| | | performValidation(input) { |
| | | const field = input.closest('.field'); |
| | | const value = this.getFieldValue(input); |
| | | const value = this.getFieldCheckedValue(input); |
| | | |
| | | if (!value && !input.required) { |
| | | return { isValid: true, message: '' }; |
| | | } |
| | | |
| | | if (input.required && !value) { |
| | | return { isValid: false, message: 'This field is required' }; |
| | | if (input.required) { |
| | | if (input.type === 'checkbox') { |
| | | if (!input.checked) { |
| | | return { isValid: false, message: 'This field is required' }; |
| | | } |
| | | } else if (input.type === 'radio') { |
| | | const radioGroup = document.querySelectorAll(`input[name="${input.name}"]`); |
| | | const anyChecked = Array.from(radioGroup).some(r => r.checked); |
| | | if (!anyChecked) { |
| | | return { isValid: false, message: 'Please select an option' }; |
| | | } |
| | | } else if (!value) { |
| | | return { isValid: false, message: 'This field is required' }; |
| | | } |
| | | } |
| | | |
| | | if(input.checkValidity && !input.checkValidity()){ |
| | |
| | | if (Object.hasOwn(field.dataset, 'validate') || input.type) { |
| | | const validator = this.validators[field.dataset.validate||input.type]; |
| | | |
| | | if (validator.pattern && !validator.pattern.test(value)) { |
| | | if (validator && validator.pattern && !validator.pattern.test(value)) { |
| | | return {isValid: false, message: validator.message}; |
| | | } |
| | | |
| | | if (validator.test) { |
| | | if (validator && validator.test) { |
| | | const result = validator.test(value, field); |
| | | if (result !== true) { |
| | | return {isValid: false, message: result}; |
| | |
| | | |
| | | handleInput(e){ |
| | | let form = this.getForm(e.target); |
| | | if (!form || !form.options.cache) return; |
| | | if (!form) return; |
| | | |
| | | let field = this.getField(e.target); |
| | | if (!field) return; |
| | | |
| | | this.showFormStatus(form, 'pending'); |
| | | const input = e.target; // Capture reference |
| | | const fieldName = field.dataset.field; |
| | | |
| | | // Show pending status regardless of cache |
| | | this.showFormStatus(form.id, 'pending'); |
| | | |
| | | // Debounce validation |
| | | window.debouncer.schedule( |
| | | `form:${form.id}:validate:${field.dataset.field}`, |
| | | () => this.validateField.bind(this), |
| | | `form:${form.id}:validate:${fieldName}`, |
| | | () => this.validateField(input), |
| | | 500 |
| | | ); |
| | | } |
| | |
| | | |
| | | if (this.subscribers.size > 0) { |
| | | e.preventDefault(); |
| | | console.log('Cancelling scheduled backup and manually backing up'); |
| | | this.cancelBackup(); |
| | | await this.backup(); |
| | | const storedData = await this.store.get(form.id); |
| | | |
| | | if (form.options.cache) { |
| | |
| | | |
| | | if (form.options.showSummary) { |
| | | const storedData = await this.store.get(form.id); |
| | | this.showSummary(form.id, { |
| | | config: form, |
| | | data: storedData?.changes || {} |
| | | }); |
| | | this.showSummary({config: form, changes: storedData?.changes}); |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | scheduleBackup() { |
| | | scheduleBackup() { |
| | | window.debouncer.schedule( |
| | | `form_changes`, |
| | | async () => { |
| | | if (this.changes.size > 0) { |
| | | await this.store.saveMany(this.changes); |
| | | for(let formId of this.changes.keys()) { |
| | | this.showFormStatus(formId, 'autosaved'); |
| | | } |
| | | this.changes.clear(); |
| | | await this.backup(); |
| | | } |
| | | }, |
| | | 2000 |
| | | ); |
| | | } |
| | | cancelBackup() { |
| | | window.debouncer.cancel('form_changes'); |
| | | } |
| | | async backup() { |
| | | // Merge with existing stored data |
| | | const toSave = new Map(); |
| | | |
| | | for (let [formId, newData] of this.changes.entries()) { |
| | | const stored = await this.store.get(formId); |
| | | |
| | | if (stored) { |
| | | // Merge changes |
| | | toSave.set(formId, { |
| | | ...stored, |
| | | ...newData, |
| | | changes: { |
| | | ...stored.changes, |
| | | ...newData.changes |
| | | }, |
| | | timestamp: Date.now() |
| | | }); |
| | | } else { |
| | | toSave.set(formId, newData); |
| | | } |
| | | } |
| | | |
| | | await this.store.saveMany(toSave); |
| | | |
| | | for (let formId of this.changes.keys()) { |
| | | this.showFormStatus(formId, 'autosaved'); |
| | | } |
| | | this.changes.clear(); |
| | | } |
| | | |
| | | saveCache(formId) { |
| | | if (!this.changes.has(formId)) return; |
| | |
| | | id: formId, |
| | | status: '', |
| | | options: { |
| | | autoUpload: false, |
| | | autoUpload: options.autoUpload??false, |
| | | imageMeta: options.imageMeta??true, |
| | | delay: options.delay??1500, |
| | | endpoint: options.save??form.dataset.save??'', |
| | | formStatus: options.showStatus??true, |
| | | showSummary: false, |
| | | showStatus: options.showStatus??true, |
| | | showSummary: options.showSummary??false, |
| | | cache: options.cache??true, |
| | | ignore: options.ignore??[] |
| | | }, |
| | | ui: window.uiFromSelectors(this.selectors.forms, form) |
| | | }; |
| | | |
| | | if (config.showSummary && !this.summaryTemplate) { |
| | | this.defineSummaryTemplate(); |
| | | } |
| | | |
| | | this.initializeFields(form, config); |
| | | this.forms.set(formId, config); |
| | | |
| | |
| | | p: 'p', |
| | | }, |
| | | setup({ el, refs, manyRefs, data }) { |
| | | const skipFields = ['sendAll', ...form.ignore]; |
| | | const skipFields = ['sendAll', ...data.config.options.ignore??[]]; |
| | | |
| | | for (let [key, value] of Object.entries(data.changes)) { |
| | | if (skipFields.includes(key) || this.isEmptyValue(value)) continue; |
| | | if (skipFields.includes(key) || form.isEmptyValue(value)) continue; |
| | | |
| | | let input = Array.from(this.inputs.values()) |
| | | let input = Array.from(form.inputs.values()) |
| | | .find(temp => temp.field?.dataset.field === key); |
| | | if (!input) continue; |
| | | |
| | |
| | | let title = entry.querySelector('h3'); |
| | | let p = entry.querySelector('p'); |
| | | |
| | | title.textContent = input.label.textContent; |
| | | // Get field label - prioritize legend for fieldsets, then label |
| | | const legend = input.field?.querySelector('legend'); |
| | | title.textContent = legend |
| | | ? legend.textContent.replace('*', '').trim() |
| | | : input.ui.label?.textContent.replace('*', '').trim(); |
| | | |
| | | if (typeof value === 'string') { |
| | | p.textContent = value; |
| | | } else if (Array.isArray(value)) { |
| | | //Repeater or Tag Item |
| | | } else if (typeof value === 'object') { |
| | | //Location item |
| | | p.textContent = `${value.address}`; |
| | | |
| | | const formattedValue = form.formatValueForSummary(value, input); |
| | | |
| | | if (formattedValue instanceof HTMLElement) { |
| | | // If it's an HTML element (repeater, tag-list, etc.), replace <p> |
| | | p.replaceWith(formattedValue); |
| | | } else { |
| | | // If it's a string, set text content |
| | | p.textContent = formattedValue; |
| | | } |
| | | |
| | | el.append(entry); |
| | |
| | | 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'); |
| | |
| | | } |
| | | ); |
| | | } |
| | | |
| | | |
| | | initializeFields(container, config = null) { |
| | | const fieldHandlers = { |
| | | '[data-editor]': () => this.checkForQuill(container,config), |
| | |
| | | '.field.tag-list': () => this.checkForTagLists(container), |
| | | '[data-depends-on]': () => this.checkForConditionalFields(container), |
| | | '[data-limit]': () => this.checkForCharacterLimits(container), |
| | | '[data-uploader]': () => this.checkForImageUploads(container, config), |
| | | '[data-uploader],[data-upload-field]': () => this.checkForImageUploads(container, config), |
| | | 'nav.tabs': () => this.checkForTabs(container, config), |
| | | '[data-type="selector"]': () => this.checkForSelectors(container) |
| | | }; |
| | |
| | | const controlField = this.dependencies.get(controlFieldName); |
| | | if (!controlField) return; |
| | | |
| | | const controlValue = this.getFieldValue(controlField.element); |
| | | const controlValue = this.getFieldCheckedValue(controlField.element); |
| | | const shouldShow = this.evaluateCondition( |
| | | controlValue, |
| | | dependentField.requiredValue, |
| | |
| | | } |
| | | } |
| | | checkForImageUploads(form, config) { |
| | | window.jvbUploads.scanFields(form, config.autoUpload); |
| | | window.jvbUploads.scanFields(form, config.options.autoUpload, config.options.imageMeta); |
| | | } |
| | | |
| | | checkForTabs(form, config) { |
| | |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce(data.message || 'Form submitted successfully'); |
| | | } |
| | | |
| | | // Trigger custom event |
| | | form.dispatchEvent(new CustomEvent('jvb-form-success', { |
| | | detail: data |
| | | })); |
| | | } |
| | | |
| | | handleFormError(form, data) { |
| | |
| | | if (!form || !form.options.showStatus || !form.ui?.status?.status) return; |
| | | if (form.status === status) return; |
| | | |
| | | |
| | | form.status = status; |
| | | form.ui.status.status.hidden = false; |
| | | form.ui.status.status.classList.toggle('loading', ['uploading', 'saving'].includes(status)); |
| | | |
| | |
| | | SUMMARY |
| | | **********************************************************************/ |
| | | showSummary(data) { |
| | | this.templates.create('formSummary', data); |
| | | let summary = this.templates.create('formSummary', data); |
| | | data.config.element.after(summary); |
| | | window.fade(data.config.element, false); |
| | | } |
| | | /********************************************************************** |
| | | UTILITY |
| | |
| | | |
| | | case 'true-false': |
| | | return element.value === '1'||element.value === 'on'||element.value ==='true'; |
| | | |
| | | case 'checkbox': |
| | | // Handle multi-checkbox (name ends with []) |
| | | if (element.name.endsWith('[]')) { |
| | | return this.getCheckboxGroupValue(element, conf); |
| | | } |
| | | return element.checked ? element.value : ''; |
| | | default: |
| | | return element.value; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get all checked values for a checkbox group |
| | | */ |
| | | getCheckboxGroupValue(element, conf) { |
| | | if (!conf.checkboxGroup) { |
| | | conf.checkboxGroup = conf.field?.querySelectorAll(`input[type="checkbox"][name="${element.name}"]`); |
| | | this.saveItem(conf); |
| | | } |
| | | |
| | | return Array.from(conf.checkboxGroup) |
| | | .filter(cb => cb.checked) |
| | | .map(cb => cb.value); |
| | | } |
| | | /** |
| | | * Get the actual user-facing value (for validation and submission) |
| | | */ |
| | | getFieldCheckedValue(element) { |
| | | // Handle checkboxes and radios based on checked state |
| | | if (element.type === 'checkbox') { |
| | | const type = this.getFieldType(element); |
| | | if (type === 'true-false') { |
| | | return element.checked; |
| | | } |
| | | return element.checked ? element.value : ''; |
| | | } |
| | | |
| | | if (element.type === 'radio') { |
| | | const radioGroup = document.querySelectorAll(`input[name="${element.name}"]`); |
| | | const checked = Array.from(radioGroup).find(r => r.checked); |
| | | return checked ? checked.value : ''; |
| | | } |
| | | |
| | | // For everything else, use existing logic |
| | | return this.getFieldValue(element); |
| | | } |
| | | |
| | | isEmptyValue(value) { |
| | | if (value === null || value === undefined || value === '') return true; |
| | | if (Array.isArray(value) && value.length === 0) return true; |
| | |
| | | return conf.value.value; |
| | | } |
| | | |
| | | /** |
| | | * Format field value for display in summary |
| | | * @param {*} value - The field value |
| | | * @param {Object} input - The input config |
| | | * @returns {HTMLElement|string} - Formatted display element or string |
| | | */ |
| | | formatValueForSummary(value, input) { |
| | | const fieldType = this.getFieldType(input.element); |
| | | |
| | | // Handle empty values |
| | | if (this.isEmptyValue(value)) { |
| | | return ''; |
| | | } |
| | | |
| | | // Handle different field types |
| | | switch (fieldType) { |
| | | case 'repeater': |
| | | return this.formatRepeaterForSummary(value, input); |
| | | |
| | | case 'tag-list': |
| | | return this.formatTagListForSummary(value, input); |
| | | |
| | | case 'location': |
| | | return this.formatLocationForSummary(value); |
| | | |
| | | case 'true-false': |
| | | return value ? 'Yes' : 'No'; |
| | | |
| | | case 'checkbox': |
| | | // Handle multi-checkbox arrays |
| | | if (Array.isArray(value)) { |
| | | return this.formatCheckboxGroupForSummary(value, input); |
| | | } |
| | | // Single checkbox - get display label |
| | | return this.getDisplayLabel(input, value); |
| | | |
| | | case 'selector': |
| | | case 'upload': |
| | | // These might need special handling depending on your needs |
| | | return this.formatHiddenFieldForSummary(value, input, fieldType); |
| | | |
| | | default: |
| | | // For radio/checkbox, get the display label |
| | | if (typeof value === 'string') { |
| | | return this.getDisplayLabel(input, value); |
| | | } |
| | | // For textarea or any multi-line text, convert line breaks |
| | | if (typeof value === 'string' && value.includes('\n')) { |
| | | return this.convertLineBreaks(value); |
| | | } |
| | | return value; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Format checkbox group values with labels |
| | | */ |
| | | formatCheckboxGroupForSummary(values, input) { |
| | | const labels = values.map(value => this.getDisplayLabel(input, value)); |
| | | return labels.join(', '); |
| | | } |
| | | |
| | | /** |
| | | * Convert \n line breaks to HTML |
| | | */ |
| | | convertLineBreaks(text) { |
| | | const container = document.createElement('span'); |
| | | container.innerHTML = text.split('\n').join('<br>'); |
| | | return container; |
| | | } |
| | | |
| | | /** |
| | | * Format repeater data as a list |
| | | */ |
| | | formatRepeaterForSummary(rows, input) { |
| | | const container = document.createElement('div'); |
| | | container.className = 'summary-repeater'; |
| | | |
| | | rows.forEach((row, index) => { |
| | | const rowDiv = document.createElement('div'); |
| | | rowDiv.className = 'summary-repeater-row'; |
| | | |
| | | const rowTitle = document.createElement('strong'); |
| | | rowTitle.textContent = `Entry ${index + 1}:`; |
| | | rowDiv.appendChild(rowTitle); |
| | | |
| | | const fieldsList = document.createElement('ul'); |
| | | fieldsList.className = 'summary-repeater-fields'; |
| | | |
| | | for (const [fieldName, fieldValue] of Object.entries(row)) { |
| | | if (this.isEmptyValue(fieldValue)) continue; |
| | | |
| | | const li = document.createElement('li'); |
| | | |
| | | // Try to find the label for this subfield |
| | | const subFieldElement = input.field?.querySelector(`[data-field="${fieldName}"]`); |
| | | const label = subFieldElement?.closest('.field')?.querySelector('label')?.textContent.replace('*', '').trim() || fieldName; |
| | | |
| | | li.innerHTML = `<span class="field-label">${label}:</span> <span class="field-value">${fieldValue}</span>`; |
| | | fieldsList.appendChild(li); |
| | | } |
| | | |
| | | rowDiv.appendChild(fieldsList); |
| | | container.appendChild(rowDiv); |
| | | }); |
| | | |
| | | return container; |
| | | } |
| | | |
| | | /** |
| | | * Format tag-list data |
| | | */ |
| | | formatTagListForSummary(tags, input) { |
| | | const container = document.createElement('div'); |
| | | container.className = 'summary-taglist'; |
| | | |
| | | const tagsList = document.createElement('ul'); |
| | | tagsList.className = 'summary-tags'; |
| | | |
| | | tags.forEach(tag => { |
| | | const li = document.createElement('li'); |
| | | li.className = 'summary-tag'; |
| | | |
| | | // Get the primary display value (first non-empty field) |
| | | const displayValue = Object.values(tag).find(v => !this.isEmptyValue(v)) || ''; |
| | | |
| | | // If there are multiple fields, show them all |
| | | const fields = Object.entries(tag).filter(([k, v]) => !this.isEmptyValue(v)); |
| | | if (fields.length > 1) { |
| | | li.textContent = fields.map(([k, v]) => v).join(', '); |
| | | } else { |
| | | li.textContent = displayValue; |
| | | } |
| | | |
| | | tagsList.appendChild(li); |
| | | }); |
| | | |
| | | container.appendChild(tagsList); |
| | | return container; |
| | | } |
| | | |
| | | /** |
| | | * Format location data |
| | | */ |
| | | formatLocationForSummary(location) { |
| | | const parts = []; |
| | | |
| | | if (location.street) parts.push(location.street); |
| | | if (location.city) parts.push(location.city); |
| | | if (location.province) parts.push(location.province); |
| | | if (location.postal_code) parts.push(location.postal_code); |
| | | if (location.country) parts.push(location.country); |
| | | |
| | | return parts.length > 0 ? parts.join(', ') : location.address || ''; |
| | | } |
| | | |
| | | /** |
| | | * Format hidden field types (upload, selector) |
| | | */ |
| | | formatHiddenFieldForSummary(value, input, fieldType) { |
| | | if (fieldType === 'upload') { |
| | | // Get upload preview images if available |
| | | const uploadField = input.field?.querySelector('[data-upload-field]'); |
| | | if (uploadField) { |
| | | const previews = uploadField.querySelectorAll('.item-grid.preview img'); |
| | | if (previews.length > 0) { |
| | | const container = document.createElement('div'); |
| | | container.className = 'summary-uploads'; |
| | | previews.forEach(img => { |
| | | const clone = img.cloneNode(true); |
| | | clone.style.maxWidth = '100px'; |
| | | clone.style.maxHeight = '100px'; |
| | | container.appendChild(clone); |
| | | }); |
| | | return container; |
| | | } |
| | | } |
| | | return `${value.split(',').length} file(s) uploaded`; |
| | | } |
| | | |
| | | if (fieldType === 'selector') { |
| | | // Could enhance this to show selected item names if available |
| | | return value; |
| | | } |
| | | |
| | | return value; |
| | | } |
| | | |
| | | /** |
| | | * Get the display label for an input value (especially for radio/checkbox) |
| | | * @param {Object} input - The input config from this.inputs |
| | | * @param {*} value - The field value |
| | | * @returns {string} - The display label or original value |
| | | */ |
| | | getDisplayLabel(input, value) { |
| | | if (!input.element) return value; |
| | | |
| | | const inputType = input.element.type; |
| | | |
| | | // Handle radio buttons |
| | | if (inputType === 'radio') { |
| | | const radioGroup = input.field.querySelectorAll(`input[type="radio"][name="${input.element.name}"]`); |
| | | const selectedRadio = Array.from(radioGroup).find(radio => radio.value === value); |
| | | if (selectedRadio) { |
| | | const label = selectedRadio.closest('label') || |
| | | input.field.querySelector(`label[for="${selectedRadio.id}"]`); |
| | | if (label) { |
| | | return label.textContent.replace('*', '').trim(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Handle checkboxes (including groups) |
| | | if (inputType === 'checkbox' && this.getFieldType(input.element) !== 'true-false') { |
| | | // Find checkbox with this value in the field |
| | | const checkbox = input.field.querySelector(`input[type="checkbox"][value="${value}"]`); |
| | | if (checkbox) { |
| | | const label = checkbox.closest('label') || |
| | | input.field.querySelector(`label[for="${checkbox.id}"]`); |
| | | if (label) { |
| | | // Get just the span content to avoid getting nested elements |
| | | const span = label.querySelector('span'); |
| | | return span ? span.textContent.trim() : label.textContent.replace('*', '').trim(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | return value; |
| | | } |
| | | getItem(element, formId = null) { |
| | | const hasID = Object.hasOwn(element.dataset, 'ref'); |
| | | let id = (hasID) ? element.dataset.ref : window.generateID('input'); |