/** * GoingsOn - Form Modal Builder * Declarative form generation, validation, and AI-fill integration */ (function() { 'use strict'; const esc = GoingsOn.utils.escapeHtml; const escAttr = GoingsOn.utils.escapeAttr; // AbortController for modal event listeners — prevents duplicate submit handlers let modalAbortController = null; /** * Field definition for form modals. * @typedef {Object} FormField * @property {string} name - Field name (used as form input name) * @property {string} type - Field type: 'text', 'textarea', 'select', 'datetime-local', 'checkbox', 'email', 'number', 'password', 'hidden' * @property {string} label - Field label * @property {string} [placeholder] - Input placeholder * @property {boolean} [required] - Whether field is required * @property {Array<{value: string, label: string, selected?: boolean}>} [options] - Options for select fields * @property {*} [value] - Default/current value * @property {string} [hint] - Help text below field * @property {Function} [onInput] - Called on input event with (value, previewEl) for live preview * @property {Object} [gridColumn] - CSS grid column (e.g., '1fr 1fr' for row) */ /** * Build and open a form modal. * @param {Object} config - Modal configuration * @param {string} config.title - Modal title * @param {string} config.entityType - Type of entity ('task', 'event', etc.) * @param {boolean} config.isEdit - Whether this is an edit (vs create) modal * @param {string} [config.entityId] - Entity ID (for edit mode) * @param {FormField[]} config.fields - Form field definitions * @param {Function} config.onSubmit - Async function to call on form submit (receives form data object) * @param {Object} [config.presetData] - Pre-filled form data * @param {string} [config.submitLabel] - Custom submit button label * @param {Function} [config.onCancel] - Custom cancel handler * @param {string} [config.extraContent] - Extra HTML content to add before form actions */ function openFormModal(config) { const { title, entityType, isEdit, entityId, fields, onSubmit, presetData = {}, submitLabel, onCancel, extraContent = '' } = config; const formId = `form-modal-${entityType}-${isEdit ? 'edit' : 'new'}`; const defaultSubmitLabel = isEdit ? 'Save Changes' : `Create ${entityType.charAt(0).toUpperCase() + entityType.slice(1)}`; // Build fields HTML const hasExtended = fields.some(f => f.extended); // Auto-expand if any extended field has a non-default value const extendedHasValues = fields.some(f => { if (!f.extended) return false; const v = presetData[f.name] ?? f.value ?? ''; if (f.type === 'select') return v !== '' && v !== 'None' && v !== 'Medium'; return v !== ''; }); let inExtended = false; const fieldsHtml = fields.map(field => { const value = presetData[field.name] ?? field.value ?? ''; const inputId = `${formId}-${field.name}`; let prefix = ''; if (hasExtended && field.extended && !inExtended) { inExtended = true; const expanded = extendedHasValues ? 'expanded' : ''; prefix = `
`; } const groupHtml = GoingsOn.ui.renderFormField({ kind: field.type, name: field.name, id: inputId, label: field.label, value, placeholder: field.placeholder, required: field.required, options: field.options, hint: field.hint, preview: !!field.onInput, }); return prefix + groupHtml; }).join('') + (inExtended ? '
' : ''); const content = `
${fieldsHtml} ${extraContent}
`; GoingsOn.ui.openModal(title, content); // Attach event handlers after modal is opened setTimeout(() => { const form = document.getElementById(formId); const cancelBtn = document.getElementById(`${formId}-cancel`); // Abort previous modal listeners to prevent duplicate submit handlers if (modalAbortController) modalAbortController.abort(); modalAbortController = new AbortController(); if (form) { form.addEventListener('submit', async (e) => { e.preventDefault(); // Collect form data first const formData = {}; const formElements = form.elements; for (const field of fields) { const el = formElements[field.name]; if (!el) continue; if (field.type === 'checkbox') { formData[field.name] = el.checked; } else if (field.type === 'number') { formData[field.name] = el.value ? Number(el.value) : null; } else { formData[field.name] = el.value; } // Apply field transform if defined if (field.transform && formData[field.name]) { formData[field.name] = field.transform(formData[field.name]); } } // Validate all fields GoingsOn.utils.clearAllFieldErrors(form); let isValid = true; let firstInvalidEl = null; for (const field of fields) { const el = formElements[field.name]; if (!el) continue; const value = formData[field.name]; // Required check if (field.required && !value?.toString().trim()) { GoingsOn.utils.showFieldError(el, `${field.label || field.name} is required`); isValid = false; if (!firstInvalidEl) firstInvalidEl = el; continue; } // Custom validator if (field.validate) { const error = field.validate(value, formData); if (error) { GoingsOn.utils.showFieldError(el, error); isValid = false; if (!firstInvalidEl) firstInvalidEl = el; } } } if (!isValid) { if (firstInvalidEl) firstInvalidEl.focus(); return; } // Add entity ID for edit mode if (isEdit && entityId) { formData._id = entityId; } // Call submit handler await onSubmit(formData, form); }, { signal: modalAbortController.signal }); } if (cancelBtn) { cancelBtn.addEventListener('click', () => { if (onCancel) { onCancel(); } else { GoingsOn.ui.closeModal(); } }, { signal: modalAbortController.signal }); } // Wire up live preview handlers (e.g., date parse preview) for (const field of fields) { if (!field.onInput) continue; const inputId = `${formId}-${field.name}`; const el = document.getElementById(inputId); const previewEl = document.getElementById(`${inputId}-preview`); if (el && previewEl) { const handler = () => field.onInput(el.value, previewEl); el.addEventListener('input', handler, { signal: modalAbortController.signal }); // Run once on open to show preview for pre-filled values if (el.value) handler(); } } }, 50); } // ============ Populate GoingsOn.ui Namespace ============ Object.assign(GoingsOn.ui, { openFormModal, }); })();