/**
* 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 = `
`;
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,
});
})();