Skip to main content

max / goingson

8.5 KB · 225 lines History Blame Raw
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 // AbortController for modal event listeners — prevents duplicate submit handlers
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 // Build fields HTML
61 const hasExtended = fields.some(f => f.extended);
62 // Auto-expand if any extended field has a non-default value
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 // Attach event handlers after modal is opened
112 setTimeout(() => {
113 const form = document.getElementById(formId);
114 const cancelBtn = document.getElementById(`${formId}-cancel`);
115
116 // Abort previous modal listeners to prevent duplicate submit handlers
117 if (modalAbortController) modalAbortController.abort();
118 modalAbortController = new AbortController();
119
120 if (form) {
121 form.addEventListener('submit', async (e) => {
122 e.preventDefault();
123
124 // Collect form data first
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 // Apply field transform if defined
141 if (field.transform && formData[field.name]) {
142 formData[field.name] = field.transform(formData[field.name]);
143 }
144 }
145
146 // Validate all fields
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 // Required check
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 // Custom validator
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 // Add entity ID for edit mode
182 if (isEdit && entityId) {
183 formData._id = entityId;
184 }
185
186 // Call submit handler
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 // Wire up live preview handlers (e.g., date parse preview)
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 // Run once on open to show preview for pre-filled values
211 if (el.value) handler();
212 }
213 }
214
215 }, 50);
216 }
217
218 // ============ Populate GoingsOn.ui Namespace ============
219
220 Object.assign(GoingsOn.ui, {
221 openFormModal,
222 });
223
224 })();
225