ultimate-jekyll-manager 0.0.119 → 0.0.121
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +102 -2
- package/README.md +171 -2
- package/TODO.md +10 -2
- package/_backup/form-manager.backup.js +1020 -0
- package/dist/assets/js/libs/auth/pages.js +64 -136
- package/dist/assets/js/libs/form-manager.js +643 -775
- package/dist/assets/js/pages/account/sections/api-keys.js +37 -52
- package/dist/assets/js/pages/account/sections/connections.js +37 -46
- package/dist/assets/js/pages/account/sections/delete.js +46 -66
- package/dist/assets/js/pages/account/sections/profile.js +37 -56
- package/dist/assets/js/pages/account/sections/security.js +100 -126
- package/dist/assets/js/pages/admin/notifications/new/index.js +72 -157
- package/dist/assets/js/pages/blog/index.js +29 -51
- package/dist/assets/js/pages/contact/index.js +110 -144
- package/dist/assets/js/pages/download/index.js +38 -86
- package/dist/assets/js/pages/oauth2/index.js +17 -17
- package/dist/assets/js/pages/payment/checkout/index.js +23 -36
- package/dist/assets/js/pages/test/libraries/form-manager/index.js +194 -0
- package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/auth/signin.html +2 -2
- package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/auth/signup.html +2 -2
- package/dist/defaults/dist/_layouts/themes/classy/frontend/pages/contact.html +10 -37
- package/dist/defaults/dist/pages/test/libraries/form-manager.html +181 -0
- package/dist/gulp/tasks/serve.js +18 -0
- package/dist/lib/logger.js +1 -1
- package/firebase-debug.log +420 -0
- package/package.json +11 -7
- package/.playwright-mcp/page-2025-10-22T19-11-27-666Z.png +0 -0
- package/.playwright-mcp/page-2025-10-22T19-11-57-357Z.png +0 -0
|
@@ -1,1020 +1,888 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* FormManager - Lightweight form state management
|
|
3
|
+
*
|
|
4
|
+
* States: initializing → ready ⇄ submitting → ready (or submitted)
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* const formManager = new FormManager('#my-form', { options });
|
|
8
|
+
* formManager.on('submit', async (data) => {
|
|
9
|
+
* const response = await fetch('/api', { body: JSON.stringify(data) });
|
|
10
|
+
* if (!response.ok) throw new Error('Failed');
|
|
11
|
+
* });
|
|
4
12
|
*/
|
|
5
13
|
|
|
6
|
-
|
|
14
|
+
// Libraries
|
|
15
|
+
import { ready as domReady } from 'web-manager/modules/dom.js';
|
|
16
|
+
import { showNotification } from 'web-manager/modules/utilities.js';
|
|
17
|
+
|
|
18
|
+
export class FormManager {
|
|
7
19
|
constructor(selector, options = {}) {
|
|
8
|
-
|
|
20
|
+
// Get form element
|
|
21
|
+
this.$form = typeof selector === 'string'
|
|
22
|
+
? document.querySelector(selector)
|
|
23
|
+
: selector;
|
|
9
24
|
|
|
10
|
-
|
|
11
|
-
|
|
25
|
+
if (!this.$form) {
|
|
26
|
+
throw new Error(`FormManager: Form not found: ${selector}`);
|
|
27
|
+
}
|
|
12
28
|
|
|
13
|
-
// Configuration
|
|
29
|
+
// Configuration
|
|
14
30
|
this.config = {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
fieldErrorClass: 'is-invalid',
|
|
24
|
-
fieldSuccessClass: 'is-valid',
|
|
25
|
-
initialState: 'loading', // Initial state: loading, ready, submitting, submitted
|
|
26
|
-
toastPosition: 'top-center', // Toast position: top-center, top-end, bottom-center, bottom-end, middle-center
|
|
27
|
-
toastDuration: 5000, // Toast duration in milliseconds
|
|
28
|
-
...options
|
|
31
|
+
autoReady: true, // Auto-transition to initialState when DOM is ready
|
|
32
|
+
initialState: 'ready', // State to transition to when autoReady fires
|
|
33
|
+
allowResubmit: true, // Allow resubmission after success (false = go to 'submitted' state)
|
|
34
|
+
resetOnSuccess: false, // Clear form fields after successful submission
|
|
35
|
+
warnOnUnsavedChanges: false, // Warn user before leaving page with unsaved changes
|
|
36
|
+
submittingText: 'Processing...', // Text shown on submit button during submission
|
|
37
|
+
submittedText: 'Processed!', // Text shown on submit button after submission (when allowResubmit: false)
|
|
38
|
+
...options,
|
|
29
39
|
};
|
|
30
40
|
|
|
31
|
-
//
|
|
32
|
-
this.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
errors: {},
|
|
42
|
-
data: {},
|
|
43
|
-
isDirty: false
|
|
41
|
+
// State
|
|
42
|
+
this.state = 'initializing';
|
|
43
|
+
this._isDirty = false;
|
|
44
|
+
|
|
45
|
+
// Event listeners
|
|
46
|
+
this._listeners = {
|
|
47
|
+
change: [],
|
|
48
|
+
validation: [],
|
|
49
|
+
submit: [],
|
|
50
|
+
statechange: [],
|
|
44
51
|
};
|
|
45
52
|
|
|
46
|
-
//
|
|
47
|
-
this.
|
|
53
|
+
// Field errors (populated during validation)
|
|
54
|
+
this._fieldErrors = {};
|
|
55
|
+
|
|
56
|
+
// Bind beforeunload handler so we can remove it later
|
|
57
|
+
this._beforeUnloadHandler = (e) => this._handleBeforeUnload(e);
|
|
58
|
+
|
|
59
|
+
/* @dev-only:start */
|
|
60
|
+
{
|
|
61
|
+
console.log('[Form-manager] Initialized', {
|
|
62
|
+
selector: typeof selector === 'string' ? selector : this.$form.id || this.$form,
|
|
63
|
+
config: this.config,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/* @dev-only:end */
|
|
48
67
|
|
|
49
68
|
// Initialize
|
|
50
|
-
this.
|
|
69
|
+
this._init();
|
|
51
70
|
}
|
|
52
71
|
|
|
53
72
|
/**
|
|
54
73
|
* Initialize the form manager
|
|
55
74
|
*/
|
|
56
|
-
|
|
57
|
-
//
|
|
58
|
-
this.
|
|
59
|
-
const state = {
|
|
60
|
-
innerHTML: button.innerHTML,
|
|
61
|
-
disabled: button.disabled
|
|
62
|
-
};
|
|
63
|
-
this.originalButtonStates.set(button, state);
|
|
75
|
+
_init() {
|
|
76
|
+
// Disable form during initialization
|
|
77
|
+
this._setDisabled(true);
|
|
64
78
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
console.log(`[FormManager] Storing button "${button.textContent.trim()}": disabled=${state.disabled}`);
|
|
68
|
-
}
|
|
69
|
-
/* @dev-only:end */
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
// Track which submit button was clicked
|
|
73
|
-
this.clickedSubmitButton = null;
|
|
79
|
+
// Attach submit handler
|
|
80
|
+
this.$form.addEventListener('submit', (e) => this._handleSubmit(e));
|
|
74
81
|
|
|
75
|
-
//
|
|
76
|
-
this.
|
|
82
|
+
// Attach change handlers
|
|
83
|
+
this.$form.addEventListener('input', (e) => this._handleChange(e));
|
|
84
|
+
this.$form.addEventListener('change', (e) => this._handleChange(e));
|
|
77
85
|
|
|
78
|
-
//
|
|
79
|
-
|
|
86
|
+
// Attach beforeunload handler if configured
|
|
87
|
+
if (this.config.warnOnUnsavedChanges) {
|
|
88
|
+
window.addEventListener('beforeunload', this._beforeUnloadHandler);
|
|
89
|
+
}
|
|
80
90
|
|
|
81
|
-
//
|
|
82
|
-
this.
|
|
91
|
+
// Handle page restored from bfcache (e.g., back button after OAuth redirect)
|
|
92
|
+
window.addEventListener('pageshow', (e) => this._handlePageShow(e));
|
|
83
93
|
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
// Auto-transition to initialState when DOM is ready
|
|
95
|
+
if (this.config.autoReady) {
|
|
96
|
+
domReady().then(() => this._setInitialState());
|
|
97
|
+
}
|
|
98
|
+
}
|
|
87
99
|
|
|
88
|
-
|
|
89
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Register event listener
|
|
102
|
+
*/
|
|
103
|
+
on(event, callback) {
|
|
104
|
+
if (!this._listeners[event]) {
|
|
105
|
+
this._listeners[event] = [];
|
|
106
|
+
}
|
|
107
|
+
this._listeners[event].push(callback);
|
|
108
|
+
return this; // Allow chaining
|
|
109
|
+
}
|
|
90
110
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Emit event to all listeners
|
|
113
|
+
*/
|
|
114
|
+
async _emit(event, data) {
|
|
115
|
+
const listeners = this._listeners[event] || [];
|
|
116
|
+
for (const callback of listeners) {
|
|
117
|
+
await callback(data);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
95
120
|
|
|
96
|
-
|
|
97
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Set initial state based on config
|
|
123
|
+
*/
|
|
124
|
+
_setInitialState() {
|
|
125
|
+
const state = this.config.initialState;
|
|
98
126
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
127
|
+
/* @dev-only:start */
|
|
128
|
+
{
|
|
129
|
+
console.log('[Form-manager] DOM ready, setting initial state:', state);
|
|
130
|
+
}
|
|
131
|
+
/* @dev-only:end */
|
|
104
132
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (!this.hasCustomInitialState) {
|
|
108
|
-
// Set ready state when DOM is ready
|
|
109
|
-
if (document.readyState === 'loading') {
|
|
110
|
-
document.addEventListener('DOMContentLoaded', () => {
|
|
111
|
-
this.isInitializing = false;
|
|
112
|
-
this.setFormState('ready');
|
|
113
|
-
});
|
|
114
|
-
} else {
|
|
115
|
-
// Use setTimeout to ensure any parent initialization completes
|
|
116
|
-
setTimeout(() => {
|
|
117
|
-
this.isInitializing = false;
|
|
118
|
-
this.setFormState('ready');
|
|
119
|
-
}, 0);
|
|
120
|
-
}
|
|
133
|
+
if (state === 'ready') {
|
|
134
|
+
this.ready();
|
|
121
135
|
} else {
|
|
122
|
-
|
|
123
|
-
this.isInitializing = false;
|
|
136
|
+
this._setState(state);
|
|
124
137
|
}
|
|
125
138
|
}
|
|
126
139
|
|
|
127
140
|
/**
|
|
128
|
-
*
|
|
141
|
+
* Transition to ready state
|
|
129
142
|
*/
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const changeHandler = (e) => this.handleChange(e);
|
|
137
|
-
|
|
138
|
-
// Attach to all form inputs
|
|
139
|
-
this.form.querySelectorAll('input, select, textarea').forEach(element => {
|
|
140
|
-
inputEvents.forEach(eventType => {
|
|
141
|
-
element.addEventListener(eventType, changeHandler);
|
|
142
|
-
});
|
|
143
|
-
});
|
|
143
|
+
ready() {
|
|
144
|
+
/* @dev-only:start */
|
|
145
|
+
{
|
|
146
|
+
console.log('[Form-manager] ready() called');
|
|
147
|
+
}
|
|
148
|
+
/* @dev-only:end */
|
|
144
149
|
|
|
145
|
-
|
|
146
|
-
this.
|
|
147
|
-
button.addEventListener('click', (e) => this.handleButtonClick(e));
|
|
148
|
-
});
|
|
150
|
+
this._setState('ready');
|
|
151
|
+
this._setDisabled(false);
|
|
149
152
|
|
|
150
|
-
//
|
|
151
|
-
this
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
});
|
|
153
|
+
// Focus the field with autofocus attribute if it exists
|
|
154
|
+
const $autofocusField = this.$form.querySelector('[autofocus]');
|
|
155
|
+
if ($autofocusField && !$autofocusField.disabled) {
|
|
156
|
+
$autofocusField.focus();
|
|
157
|
+
}
|
|
156
158
|
}
|
|
157
159
|
|
|
158
160
|
/**
|
|
159
161
|
* Handle form submission
|
|
160
162
|
*/
|
|
161
|
-
async
|
|
163
|
+
async _handleSubmit(e) {
|
|
164
|
+
// Always prevent default - this is the whole point
|
|
162
165
|
e.preventDefault();
|
|
163
166
|
|
|
164
|
-
//
|
|
165
|
-
if (this.state
|
|
167
|
+
// Ignore if not ready
|
|
168
|
+
if (this.state !== 'ready') {
|
|
169
|
+
/* @dev-only:start */
|
|
170
|
+
{
|
|
171
|
+
console.log('[Form-manager] Submit ignored, not ready. Current state:', this.state);
|
|
172
|
+
}
|
|
173
|
+
/* @dev-only:end */
|
|
166
174
|
return;
|
|
167
175
|
}
|
|
168
176
|
|
|
169
|
-
//
|
|
170
|
-
|
|
177
|
+
// Get the submit button that was clicked (native browser API)
|
|
178
|
+
const $submitButton = e.submitter;
|
|
171
179
|
|
|
172
|
-
// Collect form data
|
|
173
|
-
const
|
|
180
|
+
// Collect form data BEFORE disabling (disabled elements aren't in FormData)
|
|
181
|
+
const data = this.getData();
|
|
174
182
|
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
183
|
+
// Clear previous field errors
|
|
184
|
+
this.clearFieldErrors();
|
|
185
|
+
|
|
186
|
+
// Run validation BEFORE transitioning to submitting state
|
|
187
|
+
const validationPassed = await this._runValidation(data, $submitButton);
|
|
188
|
+
if (!validationPassed) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Transition to submitting
|
|
193
|
+
this._setState('submitting');
|
|
194
|
+
this._setDisabled(true);
|
|
195
|
+
this._showSpinner(true);
|
|
184
196
|
|
|
185
197
|
/* @dev-only:start */
|
|
186
198
|
{
|
|
187
|
-
console.log(
|
|
199
|
+
console.log('[Form-manager] Submitting', {
|
|
200
|
+
data,
|
|
201
|
+
submitButton: $submitButton?.name ? `${$submitButton.name}=${$submitButton.value}` : null,
|
|
202
|
+
});
|
|
188
203
|
}
|
|
189
204
|
/* @dev-only:end */
|
|
190
205
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
console.log('-----2');
|
|
195
|
-
const validation = this.validate(formData);
|
|
196
|
-
if (!validation.isValid) {
|
|
197
|
-
console.log('-----3');
|
|
198
|
-
this.showErrors(validation.errors);
|
|
199
|
-
// Show a summary notification for validation errors
|
|
200
|
-
const errorCount = Object.keys(validation.errors).length;
|
|
201
|
-
const message = errorCount === 1
|
|
202
|
-
? 'Please correct the error below'
|
|
203
|
-
: `Please correct the ${errorCount} errors below`;
|
|
204
|
-
this.showNotification(message, 'danger');
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Set submitting state
|
|
210
|
-
this.setFormState('submitting');
|
|
211
|
-
|
|
212
|
-
// Emit submit event with the clicked submit button
|
|
213
|
-
this.dispatchEvent(submitEvent);
|
|
214
|
-
|
|
215
|
-
// Reset clicked button after dispatching event
|
|
216
|
-
this.clickedSubmitButton = null;
|
|
217
|
-
|
|
218
|
-
// If event was not cancelled, handle default submission
|
|
219
|
-
if (!submitEvent.defaultPrevented) {
|
|
220
|
-
try {
|
|
221
|
-
// Default submission (can be overridden by listening to submit event)
|
|
222
|
-
await this.defaultSubmitHandler(formData);
|
|
223
|
-
|
|
224
|
-
// Success - set state based on allowMultipleSubmissions
|
|
225
|
-
if (this.config.allowMultipleSubmissions) {
|
|
226
|
-
this.setFormState('ready');
|
|
227
|
-
} else {
|
|
228
|
-
this.setFormState('submitted');
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
this.dispatchEvent(new CustomEvent('success', {
|
|
232
|
-
detail: { data: formData }
|
|
233
|
-
}));
|
|
206
|
+
try {
|
|
207
|
+
// Let consumers handle the submission
|
|
208
|
+
await this._emit('submit', { data, $submitButton });
|
|
234
209
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
this.setFormState('ready');
|
|
242
|
-
this.showError(error.message);
|
|
243
|
-
this.dispatchEvent(new CustomEvent('error', {
|
|
244
|
-
detail: { error, data: formData }
|
|
245
|
-
}));
|
|
210
|
+
/* @dev-only:start */
|
|
211
|
+
{
|
|
212
|
+
console.log('[Form-manager] Submit success', {
|
|
213
|
+
resetOnSuccess: this.config.resetOnSuccess,
|
|
214
|
+
allowResubmit: this.config.allowResubmit,
|
|
215
|
+
});
|
|
246
216
|
}
|
|
247
|
-
|
|
248
|
-
}
|
|
217
|
+
/* @dev-only:end */
|
|
249
218
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
async defaultSubmitHandler(data) {
|
|
254
|
-
// Default implementation - just log
|
|
255
|
-
console.log('Form submitted with data:', data);
|
|
256
|
-
// Simulate async operation
|
|
257
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
258
|
-
}
|
|
219
|
+
// Success - clear dirty state
|
|
220
|
+
this.setDirty(false);
|
|
221
|
+
this._showSpinner(false);
|
|
259
222
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
handleChange(e) {
|
|
264
|
-
const field = e.target;
|
|
223
|
+
if (this.config.resetOnSuccess) {
|
|
224
|
+
this.$form.reset();
|
|
225
|
+
}
|
|
265
226
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
227
|
+
if (this.config.allowResubmit) {
|
|
228
|
+
this._setState('ready');
|
|
229
|
+
this._setDisabled(false);
|
|
230
|
+
} else {
|
|
231
|
+
this._setState('submitted');
|
|
232
|
+
this._showSubmittedText();
|
|
233
|
+
// Stay disabled - no more submissions allowed
|
|
234
|
+
}
|
|
235
|
+
} catch (error) {
|
|
236
|
+
/* @dev-only:start */
|
|
237
|
+
{
|
|
238
|
+
console.log('[Form-manager] Submit error:', error.message);
|
|
239
|
+
}
|
|
240
|
+
/* @dev-only:end */
|
|
270
241
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
this.
|
|
242
|
+
// Error - go back to ready and show error
|
|
243
|
+
this._setState('ready');
|
|
244
|
+
this._setDisabled(false);
|
|
245
|
+
this._showSpinner(false);
|
|
246
|
+
this.showError(error.message || 'An error occurred');
|
|
276
247
|
}
|
|
277
|
-
|
|
278
|
-
// Set a new timeout to capture the final value
|
|
279
|
-
this.changeTimeouts.set(field, setTimeout(() => {
|
|
280
|
-
this.state.isDirty = true;
|
|
281
|
-
|
|
282
|
-
// Collect all form data after the change
|
|
283
|
-
const data = this.collectFormData();
|
|
284
|
-
|
|
285
|
-
// Get the specific field value
|
|
286
|
-
const fieldValue = this.getFieldValue(field);
|
|
287
|
-
|
|
288
|
-
// Emit the unified change event
|
|
289
|
-
this.dispatchEvent(new CustomEvent('change', {
|
|
290
|
-
detail: {
|
|
291
|
-
field: field,
|
|
292
|
-
fieldName: field.name,
|
|
293
|
-
fieldValue: fieldValue,
|
|
294
|
-
data: data,
|
|
295
|
-
event: e
|
|
296
|
-
}
|
|
297
|
-
}));
|
|
298
|
-
|
|
299
|
-
// Clean up the timeout reference
|
|
300
|
-
this.changeTimeouts.delete(field);
|
|
301
|
-
}, 100)); // 100ms delay to ensure we capture the final value
|
|
302
248
|
}
|
|
303
249
|
|
|
304
250
|
/**
|
|
305
|
-
* Handle
|
|
251
|
+
* Handle input changes
|
|
306
252
|
*/
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
253
|
+
_handleChange(e) {
|
|
254
|
+
// Mark form as dirty
|
|
255
|
+
this.setDirty(true);
|
|
310
256
|
|
|
311
|
-
this.
|
|
312
|
-
detail: {
|
|
313
|
-
button,
|
|
314
|
-
action,
|
|
315
|
-
data: this.collectFormData()
|
|
316
|
-
}
|
|
317
|
-
}));
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Set form state and update UI accordingly
|
|
322
|
-
*/
|
|
323
|
-
setFormState(status) {
|
|
324
|
-
const previousStatus = this.state.status;
|
|
325
|
-
this.state.status = status;
|
|
257
|
+
const data = this.getData();
|
|
326
258
|
|
|
327
259
|
/* @dev-only:start */
|
|
328
260
|
{
|
|
329
|
-
console.log(
|
|
261
|
+
console.log('[Form-manager] Change', {
|
|
262
|
+
name: e.target.name,
|
|
263
|
+
value: e.target.value,
|
|
264
|
+
data,
|
|
265
|
+
});
|
|
330
266
|
}
|
|
331
267
|
/* @dev-only:end */
|
|
332
268
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
break;
|
|
340
|
-
|
|
341
|
-
case 'ready':
|
|
342
|
-
this.enableForm();
|
|
343
|
-
this.hideSubmittingState();
|
|
344
|
-
break;
|
|
345
|
-
|
|
346
|
-
case 'submitting':
|
|
347
|
-
this.disableForm();
|
|
348
|
-
this.showSubmittingState();
|
|
349
|
-
break;
|
|
269
|
+
this._emit('change', {
|
|
270
|
+
field: e.target,
|
|
271
|
+
name: e.target.name,
|
|
272
|
+
value: e.target.value,
|
|
273
|
+
data,
|
|
274
|
+
});
|
|
350
275
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
this.disableForm();
|
|
355
|
-
}
|
|
356
|
-
this.hideSubmittingState();
|
|
357
|
-
// Update button text if submitButtonSuccessText is configured
|
|
358
|
-
if (this.config.submitButtonSuccessText && !this.config.allowMultipleSubmissions) {
|
|
359
|
-
this.showSuccessButtonText();
|
|
360
|
-
}
|
|
361
|
-
break;
|
|
276
|
+
// Clear field error when user types in that field
|
|
277
|
+
if (this._fieldErrors[e.target.name]) {
|
|
278
|
+
this._clearFieldError(e.target.name);
|
|
362
279
|
}
|
|
363
|
-
|
|
364
|
-
// Emit state change event
|
|
365
|
-
this.dispatchEvent(new CustomEvent('statechange', {
|
|
366
|
-
detail: {
|
|
367
|
-
status,
|
|
368
|
-
previousStatus
|
|
369
|
-
}
|
|
370
|
-
}));
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/**
|
|
374
|
-
* Disable all form controls
|
|
375
|
-
*/
|
|
376
|
-
disableForm() {
|
|
377
|
-
if (!this.config.autoDisable) return;
|
|
378
|
-
|
|
379
|
-
// Disable all inputs, selects, textareas, and buttons
|
|
380
|
-
this.form.querySelectorAll('input, select, textarea, button').forEach(element => {
|
|
381
|
-
element.disabled = true;
|
|
382
|
-
});
|
|
383
280
|
}
|
|
384
281
|
|
|
385
282
|
/**
|
|
386
|
-
*
|
|
283
|
+
* Run validation (HTML5 + custom validation event)
|
|
284
|
+
* Returns true if validation passed, false if there are errors
|
|
387
285
|
*/
|
|
388
|
-
|
|
389
|
-
if (!this.config.autoDisable) return;
|
|
390
|
-
|
|
286
|
+
async _runValidation(data, $submitButton) {
|
|
391
287
|
/* @dev-only:start */
|
|
392
288
|
{
|
|
393
|
-
|
|
394
|
-
console.log(`[FormManager] Enabling ${count} controls in ${this.form.id || 'form'}`);
|
|
289
|
+
console.log('[Form-manager] Running validation');
|
|
395
290
|
}
|
|
396
291
|
/* @dev-only:end */
|
|
397
292
|
|
|
398
|
-
//
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
293
|
+
// Create setError helper for custom validation
|
|
294
|
+
const setError = (fieldName, message) => {
|
|
295
|
+
this._fieldErrors[fieldName] = message;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
// 1. Run automatic HTML5 validation
|
|
299
|
+
this._runHTML5Validation(setError);
|
|
402
300
|
|
|
403
|
-
|
|
404
|
-
|
|
301
|
+
// 2. Run custom validation listeners
|
|
302
|
+
await this._emit('validation', { data, setError, $submitButton });
|
|
405
303
|
|
|
304
|
+
// 3. Check if there are any errors
|
|
305
|
+
const errorCount = Object.keys(this._fieldErrors).length;
|
|
306
|
+
if (errorCount > 0) {
|
|
406
307
|
/* @dev-only:start */
|
|
407
308
|
{
|
|
408
|
-
|
|
409
|
-
const willEnable = isSubmitButton || !originalState || !originalState.disabled;
|
|
410
|
-
console.log(`[FormManager] Button "${element.textContent.trim()}": originally ${originalState?.disabled ? 'disabled' : 'enabled'} --> ${willEnable ? 'enabling' : 'keeping disabled'}`);
|
|
411
|
-
}
|
|
309
|
+
console.log('[Form-manager] Validation failed:', this._fieldErrors);
|
|
412
310
|
}
|
|
413
311
|
/* @dev-only:end */
|
|
414
312
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
}
|
|
418
|
-
});
|
|
313
|
+
// Display all field errors
|
|
314
|
+
this._displayFieldErrors();
|
|
419
315
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
if (autofocusField && !autofocusField.disabled) {
|
|
423
|
-
autofocusField.focus();
|
|
424
|
-
}
|
|
425
|
-
}
|
|
316
|
+
// Focus first error field
|
|
317
|
+
this._focusFirstError();
|
|
426
318
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
*/
|
|
430
|
-
showSubmittingState() {
|
|
431
|
-
if (!this.config.showSpinner) return;
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
432
321
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
}
|
|
439
|
-
});
|
|
440
|
-
}
|
|
322
|
+
/* @dev-only:start */
|
|
323
|
+
{
|
|
324
|
+
console.log('[Form-manager] Validation passed');
|
|
325
|
+
}
|
|
326
|
+
/* @dev-only:end */
|
|
441
327
|
|
|
442
|
-
|
|
443
|
-
* Hide submitting state on buttons
|
|
444
|
-
*/
|
|
445
|
-
hideSubmittingState() {
|
|
446
|
-
// Restore original button content
|
|
447
|
-
this.form.querySelectorAll('button[type="submit"]').forEach(button => {
|
|
448
|
-
const originalState = this.originalButtonStates.get(button);
|
|
449
|
-
if (originalState) {
|
|
450
|
-
button.innerHTML = originalState.innerHTML;
|
|
451
|
-
}
|
|
452
|
-
});
|
|
328
|
+
return true;
|
|
453
329
|
}
|
|
454
330
|
|
|
455
331
|
/**
|
|
456
|
-
*
|
|
332
|
+
* Run HTML5 constraint validation on all form fields
|
|
457
333
|
*/
|
|
458
|
-
|
|
459
|
-
|
|
334
|
+
_runHTML5Validation(setError) {
|
|
335
|
+
const $fields = this.$form.querySelectorAll('input, select, textarea');
|
|
460
336
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
if (buttonTextSpan) {
|
|
466
|
-
buttonTextSpan.textContent = this.config.submitButtonSuccessText;
|
|
467
|
-
} else {
|
|
468
|
-
// If no button-text span, update the entire button content
|
|
469
|
-
button.textContent = this.config.submitButtonSuccessText;
|
|
337
|
+
$fields.forEach(($field) => {
|
|
338
|
+
const name = $field.name;
|
|
339
|
+
if (!name) {
|
|
340
|
+
return;
|
|
470
341
|
}
|
|
471
|
-
});
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
/**
|
|
475
|
-
* Set nested property value using dot notation
|
|
476
|
-
*/
|
|
477
|
-
setNestedValue(obj, path, value) {
|
|
478
|
-
// Check if path contains dots
|
|
479
|
-
if (!path.includes('.')) {
|
|
480
|
-
obj[path] = value;
|
|
481
|
-
return;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
const keys = path.split('.');
|
|
485
|
-
const lastKey = keys.pop();
|
|
486
342
|
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
if (!current[key] || typeof current[key] !== 'object') {
|
|
491
|
-
current[key] = {};
|
|
343
|
+
// Skip if already has an error (from previous validation)
|
|
344
|
+
if (this._fieldErrors[name]) {
|
|
345
|
+
return;
|
|
492
346
|
}
|
|
493
|
-
current = current[key];
|
|
494
|
-
}
|
|
495
347
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
}
|
|
348
|
+
const value = $field.value;
|
|
349
|
+
const type = $field.type;
|
|
499
350
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
351
|
+
// Required validation
|
|
352
|
+
if ($field.hasAttribute('required')) {
|
|
353
|
+
if (type === 'checkbox' && !$field.checked) {
|
|
354
|
+
setError(name, 'This field is required');
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (!value || !value.trim()) {
|
|
358
|
+
setError(name, 'This field is required');
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
508
362
|
|
|
509
|
-
|
|
510
|
-
|
|
363
|
+
// Skip further validation if empty and not required
|
|
364
|
+
if (!value) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
511
367
|
|
|
512
|
-
|
|
513
|
-
if (
|
|
514
|
-
|
|
368
|
+
// Email validation
|
|
369
|
+
if (type === 'email') {
|
|
370
|
+
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
371
|
+
if (!emailPattern.test(value)) {
|
|
372
|
+
setError(name, 'Please enter a valid email address');
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
515
375
|
}
|
|
516
|
-
current = current[key];
|
|
517
|
-
}
|
|
518
376
|
|
|
519
|
-
|
|
520
|
-
|
|
377
|
+
// URL validation
|
|
378
|
+
if (type === 'url') {
|
|
379
|
+
try {
|
|
380
|
+
new URL(value);
|
|
381
|
+
} catch {
|
|
382
|
+
setError(name, 'Please enter a valid URL');
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
521
386
|
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
387
|
+
// Min length validation
|
|
388
|
+
if ($field.hasAttribute('minlength')) {
|
|
389
|
+
const minLength = parseInt($field.getAttribute('minlength'), 10);
|
|
390
|
+
if (value.length < minLength) {
|
|
391
|
+
setError(name, `Must be at least ${minLength} characters`);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
528
395
|
|
|
396
|
+
// Max length validation
|
|
397
|
+
if ($field.hasAttribute('maxlength')) {
|
|
398
|
+
const maxLength = parseInt($field.getAttribute('maxlength'), 10);
|
|
399
|
+
if (value.length > maxLength) {
|
|
400
|
+
setError(name, `Must be no more than ${maxLength} characters`);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
529
404
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
} else {
|
|
539
|
-
|
|
405
|
+
// Min value validation (for number, range, date, etc.)
|
|
406
|
+
if ($field.hasAttribute('min')) {
|
|
407
|
+
const min = $field.getAttribute('min');
|
|
408
|
+
if (type === 'number' || type === 'range') {
|
|
409
|
+
if (parseFloat(value) < parseFloat(min)) {
|
|
410
|
+
setError(name, `Must be at least ${min}`);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
} else if (type === 'date' || type === 'datetime-local') {
|
|
414
|
+
if (new Date(value) < new Date(min)) {
|
|
415
|
+
setError(name, `Must be on or after ${min}`);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
540
418
|
}
|
|
541
|
-
} else {
|
|
542
|
-
this.setNestedValue(data, key, value);
|
|
543
419
|
}
|
|
544
|
-
}
|
|
545
420
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
421
|
+
// Max value validation
|
|
422
|
+
if ($field.hasAttribute('max')) {
|
|
423
|
+
const max = $field.getAttribute('max');
|
|
424
|
+
if (type === 'number' || type === 'range') {
|
|
425
|
+
if (parseFloat(value) > parseFloat(max)) {
|
|
426
|
+
setError(name, `Must be no more than ${max}`);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
} else if (type === 'date' || type === 'datetime-local') {
|
|
430
|
+
if (new Date(value) > new Date(max)) {
|
|
431
|
+
setError(name, `Must be on or before ${max}`);
|
|
432
|
+
return;
|
|
556
433
|
}
|
|
557
434
|
}
|
|
558
435
|
}
|
|
559
|
-
});
|
|
560
436
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
437
|
+
// Pattern validation
|
|
438
|
+
if ($field.hasAttribute('pattern')) {
|
|
439
|
+
const pattern = new RegExp(`^${$field.getAttribute('pattern')}$`);
|
|
440
|
+
if (!pattern.test(value)) {
|
|
441
|
+
const title = $field.getAttribute('title') || 'Please match the requested format';
|
|
442
|
+
setError(name, title);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
564
446
|
});
|
|
565
|
-
|
|
566
|
-
this.state.data = data;
|
|
567
|
-
return data;
|
|
568
447
|
}
|
|
569
448
|
|
|
570
449
|
/**
|
|
571
|
-
*
|
|
450
|
+
* Display all field errors in the DOM
|
|
572
451
|
*/
|
|
573
|
-
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
: fieldOrName;
|
|
577
|
-
|
|
578
|
-
if (!field) return undefined;
|
|
579
|
-
|
|
580
|
-
if (field.type === 'checkbox') {
|
|
581
|
-
// For multiple checkboxes with same name, return array of checked values
|
|
582
|
-
const checkboxes = this.form.querySelectorAll(`input[type="checkbox"][name="${field.name}"]`);
|
|
583
|
-
if (checkboxes.length > 1) {
|
|
584
|
-
return Array.from(checkboxes)
|
|
585
|
-
.filter(cb => cb.checked)
|
|
586
|
-
.map(cb => cb.value);
|
|
587
|
-
}
|
|
588
|
-
return field.checked;
|
|
589
|
-
} else if (field.type === 'radio') {
|
|
590
|
-
return this.form.querySelector(`input[name="${field.name}"]:checked`)?.value;
|
|
591
|
-
} else {
|
|
592
|
-
return field.value;
|
|
452
|
+
_displayFieldErrors() {
|
|
453
|
+
for (const [fieldName, message] of Object.entries(this._fieldErrors)) {
|
|
454
|
+
this._showFieldError(fieldName, message);
|
|
593
455
|
}
|
|
594
456
|
}
|
|
595
457
|
|
|
596
458
|
/**
|
|
597
|
-
*
|
|
459
|
+
* Show error on a specific field
|
|
598
460
|
*/
|
|
599
|
-
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
// Log
|
|
604
|
-
/* @dev-only:start */
|
|
605
|
-
{
|
|
606
|
-
console.log(`[FormManager] Validating form ${this.form.id || 'form'}`, data);
|
|
461
|
+
_showFieldError(fieldName, message) {
|
|
462
|
+
const $field = this.$form.querySelector(`[name="${fieldName}"]`);
|
|
463
|
+
if (!$field) {
|
|
464
|
+
return;
|
|
607
465
|
}
|
|
608
|
-
/* @dev-only:end */
|
|
609
|
-
|
|
610
|
-
// Check required fields
|
|
611
|
-
this.form.querySelectorAll('[required]').forEach(field => {
|
|
612
|
-
const value = this.getNestedValue(data, field.name);
|
|
613
466
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
isValid = false;
|
|
617
|
-
}
|
|
618
|
-
});
|
|
619
|
-
|
|
620
|
-
// Check email fields
|
|
621
|
-
this.form.querySelectorAll('input[type="email"]').forEach(field => {
|
|
622
|
-
const value = this.getNestedValue(data, field.name);
|
|
623
|
-
|
|
624
|
-
if (value && !this.isValidEmail(value)) {
|
|
625
|
-
errors[field.name] = 'Please enter a valid email address';
|
|
626
|
-
isValid = false;
|
|
627
|
-
}
|
|
628
|
-
});
|
|
467
|
+
// Add invalid class to field
|
|
468
|
+
$field.classList.add('is-invalid');
|
|
629
469
|
|
|
630
|
-
//
|
|
631
|
-
|
|
632
|
-
|
|
470
|
+
// Find or create feedback element
|
|
471
|
+
let $feedback = $field.parentElement.querySelector('.invalid-feedback');
|
|
472
|
+
if (!$feedback) {
|
|
473
|
+
$feedback = document.createElement('div');
|
|
474
|
+
$feedback.className = 'invalid-feedback';
|
|
633
475
|
|
|
634
|
-
|
|
635
|
-
if (
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
// Check minlength validation
|
|
642
|
-
this.form.querySelectorAll('[minlength]').forEach(field => {
|
|
643
|
-
const value = this.getNestedValue(data, field.name);
|
|
644
|
-
|
|
645
|
-
const minLength = parseInt(field.minLength);
|
|
646
|
-
if (value && value.length < minLength) {
|
|
647
|
-
errors[field.name] = `${this.getFieldLabel(field)} must be at least ${minLength} characters`;
|
|
648
|
-
isValid = false;
|
|
649
|
-
}
|
|
650
|
-
});
|
|
651
|
-
|
|
652
|
-
// Check maxlength validation
|
|
653
|
-
this.form.querySelectorAll('[maxlength]').forEach(field => {
|
|
654
|
-
const value = this.getNestedValue(data, field.name);
|
|
655
|
-
|
|
656
|
-
const maxLength = parseInt(field.maxLength);
|
|
657
|
-
if (value && value.length > maxLength) {
|
|
658
|
-
errors[field.name] = `${this.getFieldLabel(field)} must be no more than ${maxLength} characters`;
|
|
659
|
-
isValid = false;
|
|
660
|
-
}
|
|
661
|
-
});
|
|
662
|
-
|
|
663
|
-
// Check min validation for number inputs
|
|
664
|
-
this.form.querySelectorAll('input[type="number"][min]').forEach(field => {
|
|
665
|
-
const value = this.getNestedValue(data, field.name);
|
|
666
|
-
|
|
667
|
-
const min = parseFloat(field.min);
|
|
668
|
-
const numValue = parseFloat(value);
|
|
669
|
-
if (!isNaN(numValue) && numValue < min) {
|
|
670
|
-
errors[field.name] = `${this.getFieldLabel(field)} must be at least ${min}`;
|
|
671
|
-
isValid = false;
|
|
476
|
+
// Insert after the field (or after the label for checkboxes)
|
|
477
|
+
if ($field.type === 'checkbox' || $field.type === 'radio') {
|
|
478
|
+
const $parent = $field.closest('.form-check') || $field.parentElement;
|
|
479
|
+
$parent.appendChild($feedback);
|
|
480
|
+
} else {
|
|
481
|
+
$field.parentElement.appendChild($feedback);
|
|
672
482
|
}
|
|
673
|
-
}
|
|
483
|
+
}
|
|
674
484
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
485
|
+
$feedback.textContent = message;
|
|
486
|
+
$feedback.style.display = 'block';
|
|
487
|
+
}
|
|
678
488
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
}
|
|
685
|
-
});
|
|
489
|
+
/**
|
|
490
|
+
* Clear error on a specific field
|
|
491
|
+
*/
|
|
492
|
+
_clearFieldError(fieldName) {
|
|
493
|
+
delete this._fieldErrors[fieldName];
|
|
686
494
|
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
});
|
|
692
|
-
this.dispatchEvent(customValidation);
|
|
495
|
+
const $field = this.$form.querySelector(`[name="${fieldName}"]`);
|
|
496
|
+
if (!$field) {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
693
499
|
|
|
694
|
-
|
|
695
|
-
this.state.isValid = isValid;
|
|
696
|
-
this.state.errors = errors;
|
|
500
|
+
$field.classList.remove('is-invalid');
|
|
697
501
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
console.log(`[FormManager] Validation result for ${this.form.id || 'form'}`, { isValid, errors });
|
|
502
|
+
const $feedback = $field.parentElement.querySelector('.invalid-feedback');
|
|
503
|
+
if ($feedback) {
|
|
504
|
+
$feedback.style.display = 'none';
|
|
702
505
|
}
|
|
703
|
-
|
|
506
|
+
}
|
|
704
507
|
|
|
705
|
-
|
|
508
|
+
/**
|
|
509
|
+
* Clear all field errors
|
|
510
|
+
*/
|
|
511
|
+
clearFieldErrors() {
|
|
512
|
+
for (const fieldName of Object.keys(this._fieldErrors)) {
|
|
513
|
+
this._clearFieldError(fieldName);
|
|
514
|
+
}
|
|
515
|
+
this._fieldErrors = {};
|
|
706
516
|
}
|
|
707
517
|
|
|
708
518
|
/**
|
|
709
|
-
*
|
|
519
|
+
* Focus the first field with an error
|
|
710
520
|
*/
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
return label.textContent.replace('*', '').trim();
|
|
521
|
+
_focusFirstError() {
|
|
522
|
+
const firstFieldName = Object.keys(this._fieldErrors)[0];
|
|
523
|
+
if (!firstFieldName) {
|
|
524
|
+
return;
|
|
716
525
|
}
|
|
717
526
|
|
|
718
|
-
|
|
719
|
-
|
|
527
|
+
const $field = this.$form.querySelector(`[name="${firstFieldName}"]`);
|
|
528
|
+
if ($field) {
|
|
529
|
+
$field.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
530
|
+
$field.focus();
|
|
531
|
+
}
|
|
720
532
|
}
|
|
721
533
|
|
|
722
534
|
/**
|
|
723
|
-
*
|
|
535
|
+
* Programmatically set field errors and display them (for use in submit handler)
|
|
724
536
|
*/
|
|
725
|
-
|
|
726
|
-
|
|
537
|
+
throwFieldErrors(errors) {
|
|
538
|
+
for (const [fieldName, message] of Object.entries(errors)) {
|
|
539
|
+
this._fieldErrors[fieldName] = message;
|
|
540
|
+
}
|
|
541
|
+
this._displayFieldErrors();
|
|
542
|
+
this._focusFirstError();
|
|
543
|
+
throw new Error('Validation failed');
|
|
727
544
|
}
|
|
728
545
|
|
|
729
546
|
/**
|
|
730
|
-
*
|
|
547
|
+
* Handle beforeunload event
|
|
731
548
|
*/
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
let firstErrorField = null;
|
|
737
|
-
|
|
738
|
-
// Show field-specific errors
|
|
739
|
-
Object.entries(errors).forEach(([fieldName, error]) => {
|
|
740
|
-
const field = this.form.querySelector(`[name="${fieldName}"]`);
|
|
741
|
-
if (field) {
|
|
742
|
-
field.classList.add(this.config.fieldErrorClass);
|
|
549
|
+
_handleBeforeUnload(e) {
|
|
550
|
+
if (!this._isDirty) {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
743
553
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
554
|
+
// Standard way to trigger browser's "unsaved changes" dialog
|
|
555
|
+
e.preventDefault();
|
|
556
|
+
e.returnValue = '';
|
|
557
|
+
}
|
|
748
558
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
559
|
+
/**
|
|
560
|
+
* Handle pageshow event (bfcache restoration)
|
|
561
|
+
*/
|
|
562
|
+
_handlePageShow(e) {
|
|
563
|
+
// Only handle if page was restored from bfcache
|
|
564
|
+
if (!e.persisted) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
753
567
|
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
568
|
+
/* @dev-only:start */
|
|
569
|
+
{
|
|
570
|
+
console.log('[Form-manager] Page restored from bfcache, current state:', this.state);
|
|
571
|
+
}
|
|
572
|
+
/* @dev-only:end */
|
|
759
573
|
|
|
760
|
-
//
|
|
761
|
-
if (
|
|
762
|
-
|
|
574
|
+
// Reset form to ready if it was stuck in submitting state
|
|
575
|
+
if (this.state === 'submitting') {
|
|
576
|
+
this._showSpinner(false);
|
|
577
|
+
this.ready();
|
|
578
|
+
}
|
|
579
|
+
}
|
|
763
580
|
|
|
764
|
-
|
|
765
|
-
|
|
581
|
+
/**
|
|
582
|
+
* Set dirty state
|
|
583
|
+
*/
|
|
584
|
+
setDirty(dirty) {
|
|
585
|
+
if (this._isDirty === dirty) {
|
|
586
|
+
return;
|
|
766
587
|
}
|
|
767
588
|
|
|
768
|
-
|
|
769
|
-
const unattachedErrors = Object.entries(errors).filter(([fieldName]) => {
|
|
770
|
-
return !this.form.querySelector(`[name="${fieldName}"]`);
|
|
771
|
-
});
|
|
589
|
+
this._isDirty = dirty;
|
|
772
590
|
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
591
|
+
/* @dev-only:start */
|
|
592
|
+
{
|
|
593
|
+
console.log('[Form-manager] Dirty state:', dirty);
|
|
776
594
|
}
|
|
595
|
+
/* @dev-only:end */
|
|
777
596
|
}
|
|
778
597
|
|
|
779
598
|
/**
|
|
780
|
-
*
|
|
599
|
+
* Set form state
|
|
781
600
|
*/
|
|
782
|
-
|
|
783
|
-
const
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
$notification.innerHTML = `
|
|
787
|
-
${message}
|
|
788
|
-
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
|
789
|
-
`;
|
|
601
|
+
_setState(newState) {
|
|
602
|
+
const previousState = this.state;
|
|
603
|
+
this.state = newState;
|
|
604
|
+
this.$form.setAttribute('data-form-state', newState);
|
|
790
605
|
|
|
791
|
-
|
|
606
|
+
/* @dev-only:start */
|
|
607
|
+
{
|
|
608
|
+
console.log('[Form-manager] State change', {
|
|
609
|
+
from: previousState,
|
|
610
|
+
to: newState,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
/* @dev-only:end */
|
|
792
614
|
|
|
793
|
-
|
|
794
|
-
$notification.remove();
|
|
795
|
-
}, 5000);
|
|
615
|
+
this._emit('statechange', { state: newState, previousState });
|
|
796
616
|
}
|
|
797
617
|
|
|
798
618
|
/**
|
|
799
|
-
*
|
|
619
|
+
* Enable/disable form controls
|
|
800
620
|
*/
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
if (messageOrError instanceof Error) {
|
|
806
|
-
message = messageOrError.message;
|
|
807
|
-
console.error('FormManager Error:', messageOrError);
|
|
808
|
-
} else {
|
|
809
|
-
message = messageOrError;
|
|
810
|
-
console.error('FormManager Error:', message);
|
|
621
|
+
_setDisabled(disabled) {
|
|
622
|
+
/* @dev-only:start */
|
|
623
|
+
{
|
|
624
|
+
console.log('[Form-manager] Set disabled:', disabled);
|
|
811
625
|
}
|
|
626
|
+
/* @dev-only:end */
|
|
812
627
|
|
|
813
|
-
|
|
814
|
-
|
|
628
|
+
this.$form.querySelectorAll('button, input, select, textarea').forEach(($el) => {
|
|
629
|
+
$el.disabled = disabled;
|
|
630
|
+
});
|
|
815
631
|
}
|
|
816
632
|
|
|
817
633
|
/**
|
|
818
|
-
*
|
|
634
|
+
* Show/hide spinner on submit buttons
|
|
819
635
|
*/
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
636
|
+
_showSpinner(show) {
|
|
637
|
+
this.$form.querySelectorAll('button[type="submit"]').forEach(($btn) => {
|
|
638
|
+
if (show) {
|
|
639
|
+
// Store original content
|
|
640
|
+
$btn._originalHTML = $btn.innerHTML;
|
|
641
|
+
$btn.innerHTML = `<span class="spinner-border spinner-border-sm me-2"></span>${this.config.submittingText}`;
|
|
642
|
+
} else if ($btn._originalHTML) {
|
|
643
|
+
$btn.innerHTML = $btn._originalHTML;
|
|
644
|
+
}
|
|
824
645
|
});
|
|
646
|
+
}
|
|
825
647
|
|
|
826
|
-
|
|
827
|
-
|
|
648
|
+
/**
|
|
649
|
+
* Show submitted text on submit buttons (when allowResubmit: false)
|
|
650
|
+
*/
|
|
651
|
+
_showSubmittedText() {
|
|
652
|
+
this.$form.querySelectorAll('button[type="submit"]').forEach(($btn) => {
|
|
653
|
+
const $buttonText = $btn.querySelector('.button-text');
|
|
654
|
+
if ($buttonText) {
|
|
655
|
+
$buttonText.textContent = this.config.submittedText;
|
|
656
|
+
} else {
|
|
657
|
+
$btn.textContent = this.config.submittedText;
|
|
658
|
+
}
|
|
659
|
+
});
|
|
828
660
|
}
|
|
829
661
|
|
|
830
662
|
/**
|
|
831
|
-
*
|
|
663
|
+
* Set nested value using dot notation (e.g., "user.address.city")
|
|
832
664
|
*/
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
665
|
+
_setNested(obj, path, value) {
|
|
666
|
+
const keys = path.split('.');
|
|
667
|
+
const lastKey = keys.pop();
|
|
668
|
+
let current = obj;
|
|
836
669
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
670
|
+
for (const key of keys) {
|
|
671
|
+
if (!current[key] || typeof current[key] !== 'object') {
|
|
672
|
+
current[key] = {};
|
|
673
|
+
}
|
|
674
|
+
current = current[key];
|
|
842
675
|
}
|
|
843
676
|
|
|
844
|
-
//
|
|
845
|
-
if (
|
|
846
|
-
|
|
677
|
+
// Handle multiple values (e.g., checkboxes with same name)
|
|
678
|
+
if (current[lastKey] !== undefined) {
|
|
679
|
+
if (!Array.isArray(current[lastKey])) {
|
|
680
|
+
current[lastKey] = [current[lastKey]];
|
|
681
|
+
}
|
|
682
|
+
current[lastKey].push(value);
|
|
683
|
+
} else {
|
|
684
|
+
current[lastKey] = value;
|
|
847
685
|
}
|
|
848
686
|
}
|
|
849
687
|
|
|
850
688
|
/**
|
|
851
|
-
*
|
|
689
|
+
* Get nested value using dot notation
|
|
852
690
|
*/
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
691
|
+
_getNested(obj, path) {
|
|
692
|
+
return path.split('.').reduce((current, key) => {
|
|
693
|
+
return current && current[key] !== undefined ? current[key] : undefined;
|
|
694
|
+
}, obj);
|
|
856
695
|
}
|
|
857
696
|
|
|
858
697
|
/**
|
|
859
|
-
*
|
|
698
|
+
* Collect form data as plain object (supports dot notation for nested fields)
|
|
860
699
|
*/
|
|
861
|
-
|
|
862
|
-
this
|
|
863
|
-
|
|
864
|
-
this.state.data = {};
|
|
865
|
-
this.state.errors = {};
|
|
866
|
-
this.clearErrors();
|
|
867
|
-
this.setFormState('ready');
|
|
868
|
-
}
|
|
700
|
+
getData() {
|
|
701
|
+
const formData = new FormData(this.$form);
|
|
702
|
+
const data = {};
|
|
869
703
|
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
if (!field) {
|
|
876
|
-
return;
|
|
877
|
-
}
|
|
704
|
+
// Count checkboxes per name to detect groups vs single
|
|
705
|
+
const checkboxCounts = {};
|
|
706
|
+
this.$form.querySelectorAll('input[type="checkbox"]').forEach(($cb) => {
|
|
707
|
+
checkboxCounts[$cb.name] = (checkboxCounts[$cb.name] || 0) + 1;
|
|
708
|
+
});
|
|
878
709
|
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
if (radio) {
|
|
884
|
-
radio.checked = true;
|
|
710
|
+
for (const [key, value] of formData.entries()) {
|
|
711
|
+
// Skip checkboxes - we handle them separately
|
|
712
|
+
if (checkboxCounts[key]) {
|
|
713
|
+
continue;
|
|
885
714
|
}
|
|
886
|
-
|
|
887
|
-
field.value = value;
|
|
715
|
+
this._setNested(data, key, value);
|
|
888
716
|
}
|
|
889
717
|
|
|
890
|
-
//
|
|
891
|
-
|
|
718
|
+
// Handle checkboxes
|
|
719
|
+
const processedGroups = new Set();
|
|
720
|
+
this.$form.querySelectorAll('input[type="checkbox"]').forEach(($cb) => {
|
|
721
|
+
const name = $cb.name;
|
|
722
|
+
|
|
723
|
+
// Single checkbox: true/false
|
|
724
|
+
if (checkboxCounts[name] === 1) {
|
|
725
|
+
this._setNested(data, name, $cb.checked);
|
|
726
|
+
return;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Checkbox group: object with value: true/false (only process once per group)
|
|
730
|
+
if (processedGroups.has(name)) {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
processedGroups.add(name);
|
|
734
|
+
|
|
735
|
+
const values = {};
|
|
736
|
+
this.$form.querySelectorAll(`input[type="checkbox"][name="${name}"]`).forEach(($groupCb) => {
|
|
737
|
+
values[$groupCb.value] = $groupCb.checked;
|
|
738
|
+
});
|
|
739
|
+
this._setNested(data, name, values);
|
|
740
|
+
});
|
|
892
741
|
|
|
893
|
-
|
|
894
|
-
field.dispatchEvent(new Event('change', { bubbles: true }));
|
|
742
|
+
return data;
|
|
895
743
|
}
|
|
896
744
|
|
|
897
745
|
/**
|
|
898
|
-
*
|
|
746
|
+
* Show success message
|
|
899
747
|
*/
|
|
900
|
-
|
|
901
|
-
|
|
748
|
+
showSuccess(message) {
|
|
749
|
+
/* @dev-only:start */
|
|
750
|
+
{
|
|
751
|
+
console.log('[Form-manager] Show success:', message);
|
|
752
|
+
}
|
|
753
|
+
/* @dev-only:end */
|
|
754
|
+
|
|
755
|
+
showNotification(message, { type: 'success' });
|
|
902
756
|
}
|
|
903
757
|
|
|
904
758
|
/**
|
|
905
|
-
*
|
|
759
|
+
* Show error message
|
|
906
760
|
*/
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
761
|
+
showError(message) {
|
|
762
|
+
/* @dev-only:start */
|
|
763
|
+
{
|
|
764
|
+
console.log('[Form-manager] Show error:', message);
|
|
765
|
+
}
|
|
766
|
+
/* @dev-only:end */
|
|
767
|
+
|
|
768
|
+
showNotification(message, { type: 'danger' });
|
|
910
769
|
}
|
|
911
770
|
|
|
912
771
|
/**
|
|
913
|
-
*
|
|
772
|
+
* Reset the form
|
|
914
773
|
*/
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
774
|
+
reset() {
|
|
775
|
+
/* @dev-only:start */
|
|
776
|
+
{
|
|
777
|
+
console.log('[Form-manager] reset() called');
|
|
778
|
+
}
|
|
779
|
+
/* @dev-only:end */
|
|
780
|
+
|
|
781
|
+
this.setDirty(false);
|
|
782
|
+
this.$form.reset();
|
|
783
|
+
this._setState('ready');
|
|
918
784
|
}
|
|
919
785
|
|
|
920
786
|
/**
|
|
921
|
-
*
|
|
787
|
+
* Check if form has unsaved changes
|
|
922
788
|
*/
|
|
923
|
-
|
|
924
|
-
return this.
|
|
789
|
+
isDirty() {
|
|
790
|
+
return this._isDirty;
|
|
925
791
|
}
|
|
926
792
|
|
|
927
793
|
/**
|
|
928
|
-
*
|
|
794
|
+
* Set form data from a nested object (supports dot notation field names)
|
|
929
795
|
*/
|
|
930
|
-
|
|
931
|
-
|
|
796
|
+
setData(data) {
|
|
797
|
+
/* @dev-only:start */
|
|
798
|
+
{
|
|
799
|
+
console.log('[Form-manager] setData() called', data);
|
|
800
|
+
}
|
|
801
|
+
/* @dev-only:end */
|
|
932
802
|
|
|
933
|
-
|
|
934
|
-
|
|
803
|
+
// Flatten nested object to dot notation paths
|
|
804
|
+
const flatData = this._flattenObject(data);
|
|
935
805
|
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
} else {
|
|
940
|
-
flattened[newKey] = value;
|
|
941
|
-
}
|
|
806
|
+
// Set each field value
|
|
807
|
+
for (const [path, value] of Object.entries(flatData)) {
|
|
808
|
+
this._setFieldValue(path, value);
|
|
942
809
|
}
|
|
943
|
-
|
|
944
|
-
return flattened;
|
|
945
810
|
}
|
|
946
811
|
|
|
947
812
|
/**
|
|
948
|
-
*
|
|
949
|
-
* @param {Object} values - Object with field names as keys and values to set (supports nested objects)
|
|
813
|
+
* Flatten a nested object to dot notation paths
|
|
950
814
|
*/
|
|
951
|
-
|
|
952
|
-
|
|
815
|
+
_flattenObject(obj, prefix = '') {
|
|
816
|
+
const result = {};
|
|
953
817
|
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
Object.entries(flatValues).forEach(([name, value]) => {
|
|
958
|
-
// Find form elements by name or id
|
|
959
|
-
const element = this.form.querySelector(`[name="${name}"]`) ||
|
|
960
|
-
this.form.querySelector(`#${name}`) ||
|
|
961
|
-
this.form.querySelector(`#${name}-input`) ||
|
|
962
|
-
this.form.querySelector(`#${name}-select`);
|
|
818
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
819
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
963
820
|
|
|
964
|
-
if (!
|
|
821
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
822
|
+
// Check if this is a checkbox group (object with boolean values)
|
|
823
|
+
const isCheckboxGroup = Object.values(value).every((v) => typeof v === 'boolean');
|
|
965
824
|
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
radioGroup.forEach(radio => {
|
|
973
|
-
radio.checked = radio.value === value;
|
|
974
|
-
});
|
|
975
|
-
} else if (element.tagName === 'SELECT') {
|
|
976
|
-
// For select elements, set the value
|
|
977
|
-
element.value = value;
|
|
978
|
-
// If value doesn't exist in options, try to find by text
|
|
979
|
-
if (!element.value && value) {
|
|
980
|
-
const option = Array.from(element.options).find(opt =>
|
|
981
|
-
opt.text.toLowerCase() === value.toLowerCase()
|
|
982
|
-
);
|
|
983
|
-
if (option) element.value = option.value;
|
|
825
|
+
if (isCheckboxGroup) {
|
|
826
|
+
// Keep as object for checkbox group handling
|
|
827
|
+
result[path] = value;
|
|
828
|
+
} else {
|
|
829
|
+
// Recurse into nested object
|
|
830
|
+
Object.assign(result, this._flattenObject(value, path));
|
|
984
831
|
}
|
|
985
832
|
} else {
|
|
986
|
-
|
|
987
|
-
element.value = value || '';
|
|
833
|
+
result[path] = value;
|
|
988
834
|
}
|
|
835
|
+
}
|
|
989
836
|
|
|
990
|
-
|
|
991
|
-
const event = new Event('change', { bubbles: true });
|
|
992
|
-
element.dispatchEvent(event);
|
|
993
|
-
});
|
|
994
|
-
|
|
995
|
-
// Update form state
|
|
996
|
-
this.state.data = this.collectFormData();
|
|
837
|
+
return result;
|
|
997
838
|
}
|
|
998
839
|
|
|
999
840
|
/**
|
|
1000
|
-
*
|
|
841
|
+
* Set a single field value by name (supports dot notation)
|
|
1001
842
|
*/
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
}
|
|
843
|
+
_setFieldValue(name, value) {
|
|
844
|
+
const $fields = this.$form.querySelectorAll(`[name="${name}"]`);
|
|
1005
845
|
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
846
|
+
if ($fields.length === 0) {
|
|
847
|
+
/* @dev-only:start */
|
|
848
|
+
{
|
|
849
|
+
console.log('[Form-manager] setData: field not found:', name);
|
|
850
|
+
}
|
|
851
|
+
/* @dev-only:end */
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
1012
854
|
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
855
|
+
const $field = $fields[0];
|
|
856
|
+
const type = $field.type;
|
|
857
|
+
|
|
858
|
+
// Handle different input types
|
|
859
|
+
if (type === 'checkbox') {
|
|
860
|
+
if ($fields.length === 1) {
|
|
861
|
+
// Single checkbox: boolean value
|
|
862
|
+
$field.checked = !!value;
|
|
863
|
+
} else if (typeof value === 'object') {
|
|
864
|
+
// Checkbox group: object with value: boolean
|
|
865
|
+
$fields.forEach(($cb) => {
|
|
866
|
+
$cb.checked = !!value[$cb.value];
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
} else if (type === 'radio') {
|
|
870
|
+
// Radio group: set the one with matching value
|
|
871
|
+
$fields.forEach(($radio) => {
|
|
872
|
+
$radio.checked = $radio.value === value;
|
|
873
|
+
});
|
|
874
|
+
} else if ($field.tagName === 'SELECT') {
|
|
875
|
+
// Select: set value
|
|
876
|
+
$field.value = value;
|
|
877
|
+
} else {
|
|
878
|
+
// Text, email, textarea, etc.
|
|
879
|
+
$field.value = value;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/* @dev-only:start */
|
|
883
|
+
{
|
|
884
|
+
console.log('[Form-manager] setData: set field', { name, value, type });
|
|
885
|
+
}
|
|
886
|
+
/* @dev-only:end */
|
|
1019
887
|
}
|
|
1020
888
|
}
|