| 1 |
|
| 2 |
* GoingsOn - Form Modal Builder |
| 3 |
* Declarative form generation, validation, and AI-fill integration |
| 4 |
|
| 5 |
|
| 6 |
(function() { |
| 7 |
'use strict'; |
| 8 |
const esc = GoingsOn.utils.escapeHtml; |
| 9 |
const escAttr = GoingsOn.utils.escapeAttr; |
| 10 |
|
| 11 |
|
| 12 |
let modalAbortController = null; |
| 13 |
|
| 14 |
|
| 15 |
* Field definition for form modals. |
| 16 |
* @typedef {Object} FormField |
| 17 |
* @property {string} name - Field name (used as form input name) |
| 18 |
* @property {string} type - Field type: 'text', 'textarea', 'select', 'datetime-local', 'checkbox', 'email', 'number', 'password', 'hidden' |
| 19 |
* @property {string} label - Field label |
| 20 |
* @property {string} [placeholder] - Input placeholder |
| 21 |
* @property {boolean} [required] - Whether field is required |
| 22 |
* @property {Array<{value: string, label: string, selected?: boolean}>} [options] - Options for select fields |
| 23 |
* @property {*} [value] - Default/current value |
| 24 |
* @property {string} [hint] - Help text below field |
| 25 |
* @property {Function} [onInput] - Called on input event with (value, previewEl) for live preview |
| 26 |
* @property {Object} [gridColumn] - CSS grid column (e.g., '1fr 1fr' for row) |
| 27 |
|
| 28 |
|
| 29 |
|
| 30 |
* Build and open a form modal. |
| 31 |
* @param {Object} config - Modal configuration |
| 32 |
* @param {string} config.title - Modal title |
| 33 |
* @param {string} config.entityType - Type of entity ('task', 'event', etc.) |
| 34 |
* @param {boolean} config.isEdit - Whether this is an edit (vs create) modal |
| 35 |
* @param {string} [config.entityId] - Entity ID (for edit mode) |
| 36 |
* @param {FormField[]} config.fields - Form field definitions |
| 37 |
* @param {Function} config.onSubmit - Async function to call on form submit (receives form data object) |
| 38 |
* @param {Object} [config.presetData] - Pre-filled form data |
| 39 |
* @param {string} [config.submitLabel] - Custom submit button label |
| 40 |
* @param {Function} [config.onCancel] - Custom cancel handler |
| 41 |
* @param {string} [config.extraContent] - Extra HTML content to add before form actions |
| 42 |
|
| 43 |
function openFormModal(config) { |
| 44 |
const { |
| 45 |
title, |
| 46 |
entityType, |
| 47 |
isEdit, |
| 48 |
entityId, |
| 49 |
fields, |
| 50 |
onSubmit, |
| 51 |
presetData = {}, |
| 52 |
submitLabel, |
| 53 |
onCancel, |
| 54 |
extraContent = '' |
| 55 |
} = config; |
| 56 |
|
| 57 |
const formId = `form-modal-${entityType}-${isEdit ? 'edit' : 'new'}`; |
| 58 |
const defaultSubmitLabel = isEdit ? 'Save Changes' : `Create ${entityType.charAt(0).toUpperCase() + entityType.slice(1)}`; |
| 59 |
|
| 60 |
|
| 61 |
const hasExtended = fields.some(f => f.extended); |
| 62 |
|
| 63 |
const extendedHasValues = fields.some(f => { |
| 64 |
if (!f.extended) return false; |
| 65 |
const v = presetData[f.name] ?? f.value ?? ''; |
| 66 |
if (f.type === 'select') return v !== '' && v !== 'None' && v !== 'Medium'; |
| 67 |
return v !== ''; |
| 68 |
}); |
| 69 |
|
| 70 |
let inExtended = false; |
| 71 |
const fieldsHtml = fields.map(field => { |
| 72 |
const value = presetData[field.name] ?? field.value ?? ''; |
| 73 |
const inputId = `${formId}-${field.name}`; |
| 74 |
|
| 75 |
let prefix = ''; |
| 76 |
if (hasExtended && field.extended && !inExtended) { |
| 77 |
inExtended = true; |
| 78 |
const expanded = extendedHasValues ? 'expanded' : ''; |
| 79 |
prefix = `<button type="button" class="form-more-toggle ${expanded}" onclick="this.classList.toggle('expanded'); this.nextElementSibling.classList.toggle('hidden'); this.textContent = this.classList.contains('expanded') ? 'Less options' : 'More options';">${extendedHasValues ? 'Less options' : 'More options'}</button><div class="form-extended-fields ${extendedHasValues ? '' : 'hidden'}">`; |
| 80 |
} |
| 81 |
|
| 82 |
const groupHtml = GoingsOn.ui.renderFormField({ |
| 83 |
kind: field.type, |
| 84 |
name: field.name, |
| 85 |
id: inputId, |
| 86 |
label: field.label, |
| 87 |
value, |
| 88 |
placeholder: field.placeholder, |
| 89 |
required: field.required, |
| 90 |
options: field.options, |
| 91 |
hint: field.hint, |
| 92 |
preview: !!field.onInput, |
| 93 |
}); |
| 94 |
|
| 95 |
return prefix + groupHtml; |
| 96 |
}).join('') + (inExtended ? '</div>' : ''); |
| 97 |
|
| 98 |
const content = ` |
| 99 |
<form id="${formId}"> |
| 100 |
${fieldsHtml} |
| 101 |
${extraContent} |
| 102 |
<div class="form-actions"> |
| 103 |
<button type="button" class="btn btn-secondary" id="${formId}-cancel">Cancel</button> |
| 104 |
<button type="submit" class="btn btn-primary">${esc(submitLabel || defaultSubmitLabel)}</button> |
| 105 |
</div> |
| 106 |
</form> |
| 107 |
`; |
| 108 |
|
| 109 |
GoingsOn.ui.openModal(title, content); |
| 110 |
|
| 111 |
|
| 112 |
setTimeout(() => { |
| 113 |
const form = document.getElementById(formId); |
| 114 |
const cancelBtn = document.getElementById(`${formId}-cancel`); |
| 115 |
|
| 116 |
|
| 117 |
if (modalAbortController) modalAbortController.abort(); |
| 118 |
modalAbortController = new AbortController(); |
| 119 |
|
| 120 |
if (form) { |
| 121 |
form.addEventListener('submit', async (e) => { |
| 122 |
e.preventDefault(); |
| 123 |
|
| 124 |
|
| 125 |
const formData = {}; |
| 126 |
const formElements = form.elements; |
| 127 |
|
| 128 |
for (const field of fields) { |
| 129 |
const el = formElements[field.name]; |
| 130 |
if (!el) continue; |
| 131 |
|
| 132 |
if (field.type === 'checkbox') { |
| 133 |
formData[field.name] = el.checked; |
| 134 |
} else if (field.type === 'number') { |
| 135 |
formData[field.name] = el.value ? Number(el.value) : null; |
| 136 |
} else { |
| 137 |
formData[field.name] = el.value; |
| 138 |
} |
| 139 |
|
| 140 |
|
| 141 |
if (field.transform && formData[field.name]) { |
| 142 |
formData[field.name] = field.transform(formData[field.name]); |
| 143 |
} |
| 144 |
} |
| 145 |
|
| 146 |
|
| 147 |
GoingsOn.utils.clearAllFieldErrors(form); |
| 148 |
let isValid = true; |
| 149 |
let firstInvalidEl = null; |
| 150 |
|
| 151 |
for (const field of fields) { |
| 152 |
const el = formElements[field.name]; |
| 153 |
if (!el) continue; |
| 154 |
|
| 155 |
const value = formData[field.name]; |
| 156 |
|
| 157 |
|
| 158 |
if (field.required && !value?.toString().trim()) { |
| 159 |
GoingsOn.utils.showFieldError(el, `${field.label || field.name} is required`); |
| 160 |
isValid = false; |
| 161 |
if (!firstInvalidEl) firstInvalidEl = el; |
| 162 |
continue; |
| 163 |
} |
| 164 |
|
| 165 |
|
| 166 |
if (field.validate) { |
| 167 |
const error = field.validate(value, formData); |
| 168 |
if (error) { |
| 169 |
GoingsOn.utils.showFieldError(el, error); |
| 170 |
isValid = false; |
| 171 |
if (!firstInvalidEl) firstInvalidEl = el; |
| 172 |
} |
| 173 |
} |
| 174 |
} |
| 175 |
|
| 176 |
if (!isValid) { |
| 177 |
if (firstInvalidEl) firstInvalidEl.focus(); |
| 178 |
return; |
| 179 |
} |
| 180 |
|
| 181 |
|
| 182 |
if (isEdit && entityId) { |
| 183 |
formData._id = entityId; |
| 184 |
} |
| 185 |
|
| 186 |
|
| 187 |
await onSubmit(formData, form); |
| 188 |
}, { signal: modalAbortController.signal }); |
| 189 |
} |
| 190 |
|
| 191 |
if (cancelBtn) { |
| 192 |
cancelBtn.addEventListener('click', () => { |
| 193 |
if (onCancel) { |
| 194 |
onCancel(); |
| 195 |
} else { |
| 196 |
GoingsOn.ui.closeModal(); |
| 197 |
} |
| 198 |
}, { signal: modalAbortController.signal }); |
| 199 |
} |
| 200 |
|
| 201 |
|
| 202 |
for (const field of fields) { |
| 203 |
if (!field.onInput) continue; |
| 204 |
const inputId = `${formId}-${field.name}`; |
| 205 |
const el = document.getElementById(inputId); |
| 206 |
const previewEl = document.getElementById(`${inputId}-preview`); |
| 207 |
if (el && previewEl) { |
| 208 |
const handler = () => field.onInput(el.value, previewEl); |
| 209 |
el.addEventListener('input', handler, { signal: modalAbortController.signal }); |
| 210 |
|
| 211 |
if (el.value) handler(); |
| 212 |
} |
| 213 |
} |
| 214 |
|
| 215 |
}, 50); |
| 216 |
} |
| 217 |
|
| 218 |
|
| 219 |
|
| 220 |
Object.assign(GoingsOn.ui, { |
| 221 |
openFormModal, |
| 222 |
}); |
| 223 |
|
| 224 |
})(); |
| 225 |
|