Skip to main content

max / goingson

8.8 KB · 228 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.escapeAttrValue;
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}" data-act="ui.toggleExpand" data-a1="@el" data-text-expanded="Less options" data-text-collapsed="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 (may be async, e.g. the
141 // Rust-backed natural-date parse).
142 if (field.transform && formData[field.name]) {
143 formData[field.name] = await field.transform(formData[field.name]);
144 }
145 }
146
147 // Validate all fields
148 GoingsOn.utils.clearAllFieldErrors(form);
149 let isValid = true;
150 let firstInvalidEl = null;
151
152 for (const field of fields) {
153 const el = formElements[field.name];
154 if (!el) continue;
155
156 const value = formData[field.name];
157
158 // Required check
159 if (field.required && !value?.toString().trim()) {
160 GoingsOn.utils.showFieldError(el, `${field.label || field.name} is required`);
161 isValid = false;
162 if (!firstInvalidEl) firstInvalidEl = el;
163 continue;
164 }
165
166 // Custom validator
167 if (field.validate) {
168 const error = field.validate(value, formData);
169 if (error) {
170 GoingsOn.utils.showFieldError(el, error);
171 isValid = false;
172 if (!firstInvalidEl) firstInvalidEl = el;
173 }
174 }
175 }
176
177 if (!isValid) {
178 if (firstInvalidEl) firstInvalidEl.focus();
179 return;
180 }
181
182 // Add entity ID for edit mode
183 if (isEdit && entityId) {
184 formData._id = entityId;
185 }
186
187 // Call submit handler
188 await onSubmit(formData, form);
189 }, { signal: modalAbortController.signal });
190 }
191
192 if (cancelBtn) {
193 cancelBtn.addEventListener('click', () => {
194 if (onCancel) {
195 onCancel();
196 } else {
197 GoingsOn.ui.closeModal();
198 }
199 }, { signal: modalAbortController.signal });
200 }
201
202 // Wire up live preview handlers (e.g., date parse preview)
203 for (const field of fields) {
204 if (!field.onInput) continue;
205 const inputId = `${formId}-${field.name}`;
206 const el = document.getElementById(inputId);
207 const previewEl = document.getElementById(`${inputId}-preview`);
208 if (el && previewEl) {
209 // onInput may be async (e.g. the Rust-backed date parse). Debounce
210 // so typing doesn't fire an IPC call per keystroke.
211 const debounced = GoingsOn.utils.debounce((v) => field.onInput(v, previewEl), 200);
212 el.addEventListener('input', () => debounced(el.value), { signal: modalAbortController.signal });
213 // Run once on open to show preview for pre-filled values
214 if (el.value) field.onInput(el.value, previewEl);
215 }
216 }
217
218 }, 50);
219 }
220
221 // ============ Populate GoingsOn.ui Namespace ============
222
223 Object.assign(GoingsOn.ui, {
224 openFormModal,
225 });
226
227 })();
228