@gofranz/formshive-submit 1.0.0
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/README.md +619 -0
- package/dist/client.d.ts +22 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/field-errors.d.ts +95 -0
- package/dist/field-errors.d.ts.map +1 -0
- package/dist/file-handler.d.ts +43 -0
- package/dist/file-handler.d.ts.map +1 -0
- package/dist/formshive-submit.cjs +1714 -0
- package/dist/formshive-submit.cjs.map +1 -0
- package/dist/formshive-submit.esm.js +1644 -0
- package/dist/formshive-submit.esm.js.map +1 -0
- package/dist/formshive-submit.js +1720 -0
- package/dist/formshive-submit.js.map +1 -0
- package/dist/formshive-submit.min.js +2 -0
- package/dist/formshive-submit.min.js.map +1 -0
- package/dist/main.d.ts +66 -0
- package/dist/main.d.ts.map +1 -0
- package/dist/retry.d.ts +73 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/submit.d.ts +49 -0
- package/dist/submit.d.ts.map +1 -0
- package/dist/test.html +1074 -0
- package/dist/types.d.ts +145 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils.d.ts +115 -0
- package/dist/utils.d.ts.map +1 -0
- package/package.json +52 -0
|
@@ -0,0 +1,1714 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, '__esModule', { value: true });
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* TypeScript type definitions for Formshive Submit library
|
|
7
|
+
*/
|
|
8
|
+
// Default configurations
|
|
9
|
+
const DEFAULT_RETRY_CONFIG = {
|
|
10
|
+
maxAttempts: 3,
|
|
11
|
+
baseDelay: 1000,
|
|
12
|
+
maxDelay: 30000,
|
|
13
|
+
enableJitter: true,
|
|
14
|
+
backoffMultiplier: 2,
|
|
15
|
+
};
|
|
16
|
+
const DEFAULT_FILE_CONFIG = {
|
|
17
|
+
maxFileSize: 10 * 1024 * 1024, // 10MB
|
|
18
|
+
allowedTypes: [], // Empty array means allow all types
|
|
19
|
+
trackProgress: true,
|
|
20
|
+
};
|
|
21
|
+
const DEFAULT_OPTIONS = {
|
|
22
|
+
endpoint: 'https://api.formshive.com/v1',
|
|
23
|
+
httpClient: 'fetch',
|
|
24
|
+
timeout: 30000,
|
|
25
|
+
debug: false,
|
|
26
|
+
retry: DEFAULT_RETRY_CONFIG,
|
|
27
|
+
files: DEFAULT_FILE_CONFIG,
|
|
28
|
+
};
|
|
29
|
+
// Export error codes
|
|
30
|
+
const ERROR_CODES = {
|
|
31
|
+
NETWORK_ERROR: 'NETWORK_ERROR',
|
|
32
|
+
TIMEOUT_ERROR: 'TIMEOUT_ERROR',
|
|
33
|
+
VALIDATION_ERROR: 'VALIDATION_ERROR',
|
|
34
|
+
FILE_TOO_LARGE: 'FILE_TOO_LARGE',
|
|
35
|
+
INVALID_FILE_TYPE: 'INVALID_FILE_TYPE',
|
|
36
|
+
FORM_NOT_FOUND: 'FORM_NOT_FOUND',
|
|
37
|
+
SERVER_ERROR: 'SERVER_ERROR',
|
|
38
|
+
RATE_LIMITED: 'RATE_LIMITED',
|
|
39
|
+
UNKNOWN_ERROR: 'UNKNOWN_ERROR',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* HTTP Client abstraction for both fetch and axios
|
|
44
|
+
*/
|
|
45
|
+
/**
|
|
46
|
+
* Fetch-based HTTP client adapter
|
|
47
|
+
*/
|
|
48
|
+
class FetchAdapter {
|
|
49
|
+
async request(config) {
|
|
50
|
+
const controller = new AbortController();
|
|
51
|
+
let timeoutId = null;
|
|
52
|
+
if (config.timeout) {
|
|
53
|
+
timeoutId = setTimeout(() => controller.abort(), config.timeout);
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const response = await fetch(config.url, {
|
|
57
|
+
method: config.method,
|
|
58
|
+
headers: {
|
|
59
|
+
...config.headers,
|
|
60
|
+
},
|
|
61
|
+
body: config.data,
|
|
62
|
+
signal: controller.signal,
|
|
63
|
+
});
|
|
64
|
+
if (timeoutId)
|
|
65
|
+
clearTimeout(timeoutId);
|
|
66
|
+
// Convert headers to record
|
|
67
|
+
const headers = {};
|
|
68
|
+
response.headers.forEach((value, key) => {
|
|
69
|
+
headers[key] = value;
|
|
70
|
+
});
|
|
71
|
+
let data;
|
|
72
|
+
const contentType = response.headers.get('content-type');
|
|
73
|
+
if (contentType?.includes('application/json')) {
|
|
74
|
+
data = await response.json();
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
data = await response.text();
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
data,
|
|
81
|
+
status: response.status,
|
|
82
|
+
statusText: response.statusText,
|
|
83
|
+
headers,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
if (timeoutId)
|
|
88
|
+
clearTimeout(timeoutId);
|
|
89
|
+
if (error instanceof Error && error.name === 'AbortError') {
|
|
90
|
+
throw new Error('Request timeout');
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
supportsProgress() {
|
|
96
|
+
// Note: fetch doesn't support upload progress natively
|
|
97
|
+
// This would require using streams which is complex
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Axios-based HTTP client adapter
|
|
103
|
+
*/
|
|
104
|
+
class AxiosAdapter {
|
|
105
|
+
constructor(axiosInstance) {
|
|
106
|
+
this.axiosInstance = axiosInstance;
|
|
107
|
+
}
|
|
108
|
+
async request(config) {
|
|
109
|
+
try {
|
|
110
|
+
const response = await this.axiosInstance.request({
|
|
111
|
+
url: config.url,
|
|
112
|
+
method: config.method,
|
|
113
|
+
data: config.data,
|
|
114
|
+
headers: config.headers,
|
|
115
|
+
timeout: config.timeout,
|
|
116
|
+
onUploadProgress: config.onUploadProgress,
|
|
117
|
+
});
|
|
118
|
+
return {
|
|
119
|
+
data: response.data,
|
|
120
|
+
status: response.status,
|
|
121
|
+
statusText: response.statusText,
|
|
122
|
+
headers: response.headers,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
// Re-throw axios errors as they contain useful information
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
supportsProgress() {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Create HTTP client adapter based on the provided client type
|
|
136
|
+
*/
|
|
137
|
+
function createHttpClient(client) {
|
|
138
|
+
if (typeof client === 'string') {
|
|
139
|
+
switch (client) {
|
|
140
|
+
case 'fetch':
|
|
141
|
+
return new FetchAdapter();
|
|
142
|
+
case 'axios':
|
|
143
|
+
// Try to import axios dynamically
|
|
144
|
+
try {
|
|
145
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
146
|
+
const axios = require('axios');
|
|
147
|
+
return new AxiosAdapter(axios.create());
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
throw new Error('Axios is not available. Please install axios or use fetch client.');
|
|
151
|
+
}
|
|
152
|
+
default:
|
|
153
|
+
throw new Error(`Unsupported HTTP client: ${client}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
// Assume it's an Axios instance
|
|
158
|
+
return new AxiosAdapter(client);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Try to parse field validation errors from response data
|
|
163
|
+
*/
|
|
164
|
+
function parseFieldValidationErrors(responseData) {
|
|
165
|
+
if (!responseData || typeof responseData !== 'object') {
|
|
166
|
+
return {};
|
|
167
|
+
}
|
|
168
|
+
// Check if this is a Formshive field validation error response
|
|
169
|
+
if (responseData.error === 'validation_error' &&
|
|
170
|
+
Array.isArray(responseData.errors)) {
|
|
171
|
+
const validationResponse = responseData;
|
|
172
|
+
return {
|
|
173
|
+
fieldErrors: validationResponse.errors,
|
|
174
|
+
validationResponse: validationResponse
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
return {};
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Create a SubmitError from various error types
|
|
181
|
+
*/
|
|
182
|
+
function createSubmitError(error, attempt, code = 'UNKNOWN_ERROR') {
|
|
183
|
+
let message = 'Unknown error';
|
|
184
|
+
let statusCode;
|
|
185
|
+
let response;
|
|
186
|
+
let isRetryable = true;
|
|
187
|
+
let fieldErrors;
|
|
188
|
+
let validationResponse;
|
|
189
|
+
// Handle axios errors
|
|
190
|
+
if (error.isAxiosError) {
|
|
191
|
+
const axiosError = error;
|
|
192
|
+
message = axiosError.message;
|
|
193
|
+
statusCode = axiosError.response?.status;
|
|
194
|
+
response = axiosError.response?.data;
|
|
195
|
+
// Parse field validation errors if this is a 400 response
|
|
196
|
+
if (statusCode === 400) {
|
|
197
|
+
const fieldValidation = parseFieldValidationErrors(response);
|
|
198
|
+
fieldErrors = fieldValidation.fieldErrors;
|
|
199
|
+
validationResponse = fieldValidation.validationResponse;
|
|
200
|
+
// Use more specific message from validation response
|
|
201
|
+
if (validationResponse?.message) {
|
|
202
|
+
message = validationResponse.message;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Determine if error is retryable
|
|
206
|
+
if (statusCode) {
|
|
207
|
+
// Don't retry client errors except 429 (rate limiting)
|
|
208
|
+
if (statusCode >= 400 && statusCode < 500 && statusCode !== 429) {
|
|
209
|
+
isRetryable = false;
|
|
210
|
+
}
|
|
211
|
+
// Set appropriate error code
|
|
212
|
+
if (statusCode === 404) {
|
|
213
|
+
code = 'FORM_NOT_FOUND';
|
|
214
|
+
}
|
|
215
|
+
else if (statusCode === 429) {
|
|
216
|
+
code = 'RATE_LIMITED';
|
|
217
|
+
}
|
|
218
|
+
else if (statusCode >= 400 && statusCode < 500) {
|
|
219
|
+
code = 'VALIDATION_ERROR';
|
|
220
|
+
}
|
|
221
|
+
else if (statusCode >= 500) {
|
|
222
|
+
code = 'SERVER_ERROR';
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Handle fetch errors
|
|
227
|
+
else if (error instanceof Error) {
|
|
228
|
+
message = error.message;
|
|
229
|
+
if (error.message.includes('timeout') || error.message.includes('Timeout')) {
|
|
230
|
+
code = 'TIMEOUT_ERROR';
|
|
231
|
+
}
|
|
232
|
+
else if (error.message.includes('network') || error.message.includes('fetch')) {
|
|
233
|
+
code = 'NETWORK_ERROR';
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
// Handle Response objects (from fetch)
|
|
237
|
+
else if (error instanceof Response) {
|
|
238
|
+
statusCode = error.status;
|
|
239
|
+
message = `HTTP ${error.status}: ${error.statusText}`;
|
|
240
|
+
// Try to extract response data for field validation errors
|
|
241
|
+
if (statusCode === 400) {
|
|
242
|
+
try {
|
|
243
|
+
// If response body is available, try to parse it
|
|
244
|
+
if (error.body) {
|
|
245
|
+
error.json().then(data => {
|
|
246
|
+
const fieldValidation = parseFieldValidationErrors(data);
|
|
247
|
+
if (fieldValidation.validationResponse?.message) {
|
|
248
|
+
message = fieldValidation.validationResponse.message;
|
|
249
|
+
}
|
|
250
|
+
}).catch(() => {
|
|
251
|
+
// Ignore JSON parsing errors for Response objects
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
catch (e) {
|
|
256
|
+
// Ignore parsing errors
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
// Determine if error is retryable
|
|
260
|
+
if (statusCode >= 400 && statusCode < 500 && statusCode !== 429) {
|
|
261
|
+
isRetryable = false;
|
|
262
|
+
}
|
|
263
|
+
// Set appropriate error code
|
|
264
|
+
if (statusCode === 404) {
|
|
265
|
+
code = 'FORM_NOT_FOUND';
|
|
266
|
+
}
|
|
267
|
+
else if (statusCode === 429) {
|
|
268
|
+
code = 'RATE_LIMITED';
|
|
269
|
+
}
|
|
270
|
+
else if (statusCode >= 400 && statusCode < 500) {
|
|
271
|
+
code = 'VALIDATION_ERROR';
|
|
272
|
+
}
|
|
273
|
+
else if (statusCode >= 500) {
|
|
274
|
+
code = 'SERVER_ERROR';
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
const submitError = new Error(message);
|
|
278
|
+
submitError.name = 'SubmitError';
|
|
279
|
+
submitError.code = ERROR_CODES[code];
|
|
280
|
+
submitError.statusCode = statusCode || undefined;
|
|
281
|
+
submitError.response = response;
|
|
282
|
+
submitError.attempt = attempt;
|
|
283
|
+
submitError.isRetryable = isRetryable;
|
|
284
|
+
submitError.originalError = error;
|
|
285
|
+
submitError.fieldErrors = fieldErrors;
|
|
286
|
+
submitError.validationResponse = validationResponse;
|
|
287
|
+
return submitError;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Check if an error is retryable
|
|
291
|
+
*/
|
|
292
|
+
function isRetryableError(error) {
|
|
293
|
+
// Network errors are retryable
|
|
294
|
+
if (error instanceof TypeError && error.message.includes('fetch')) {
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
// Timeout errors are retryable
|
|
298
|
+
if (error.message?.includes('timeout') || error.message?.includes('Timeout')) {
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
// Server errors (5xx) are retryable
|
|
302
|
+
if (error.response?.status >= 500) {
|
|
303
|
+
return true;
|
|
304
|
+
}
|
|
305
|
+
// Rate limiting (429) is retryable
|
|
306
|
+
if (error.response?.status === 429) {
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
// Axios network errors are retryable
|
|
310
|
+
if (error.isAxiosError && !error.response) {
|
|
311
|
+
return true;
|
|
312
|
+
}
|
|
313
|
+
// Client errors (4xx except 429) are not retryable
|
|
314
|
+
if (error.response?.status >= 400 && error.response?.status < 500 && error.response?.status !== 429) {
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Get user-friendly error message based on error type
|
|
321
|
+
*/
|
|
322
|
+
function getErrorMessage(error) {
|
|
323
|
+
switch (error.code) {
|
|
324
|
+
case ERROR_CODES.FORM_NOT_FOUND:
|
|
325
|
+
return 'Form not found. Please check the form ID.';
|
|
326
|
+
case ERROR_CODES.VALIDATION_ERROR:
|
|
327
|
+
return error.response?.message || 'Form validation failed. Please check your data.';
|
|
328
|
+
case ERROR_CODES.FILE_TOO_LARGE:
|
|
329
|
+
return 'One or more files are too large. Please reduce file size and try again.';
|
|
330
|
+
case ERROR_CODES.INVALID_FILE_TYPE:
|
|
331
|
+
return 'Invalid file type. Please check allowed file types.';
|
|
332
|
+
case ERROR_CODES.RATE_LIMITED:
|
|
333
|
+
return 'Too many requests. Please wait a moment and try again.';
|
|
334
|
+
case ERROR_CODES.TIMEOUT_ERROR:
|
|
335
|
+
return 'Request timed out. Please check your connection and try again.';
|
|
336
|
+
case ERROR_CODES.NETWORK_ERROR:
|
|
337
|
+
return 'Network error. Please check your internet connection.';
|
|
338
|
+
case ERROR_CODES.SERVER_ERROR:
|
|
339
|
+
return 'Server error. Please try again in a few minutes.';
|
|
340
|
+
default:
|
|
341
|
+
return error.message || 'An unexpected error occurred.';
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Retry mechanism with exponential backoff and jitter
|
|
347
|
+
*/
|
|
348
|
+
/**
|
|
349
|
+
* Sleep utility for retry delays
|
|
350
|
+
*/
|
|
351
|
+
function sleep(ms) {
|
|
352
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Calculate delay with exponential backoff and optional jitter
|
|
356
|
+
*/
|
|
357
|
+
function calculateDelay(attempt, config) {
|
|
358
|
+
const exponentialDelay = config.baseDelay * Math.pow(config.backoffMultiplier, attempt - 1);
|
|
359
|
+
// Apply maximum delay limit
|
|
360
|
+
const cappedDelay = Math.min(exponentialDelay, config.maxDelay);
|
|
361
|
+
// Add jitter to avoid thundering herd problem
|
|
362
|
+
if (config.enableJitter) {
|
|
363
|
+
// Random jitter between 0% and 25% of the delay
|
|
364
|
+
const jitterRange = cappedDelay * 0.25;
|
|
365
|
+
const jitter = Math.random() * jitterRange;
|
|
366
|
+
return Math.floor(cappedDelay + jitter);
|
|
367
|
+
}
|
|
368
|
+
return cappedDelay;
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* Retry function with exponential backoff
|
|
372
|
+
*/
|
|
373
|
+
async function withRetry(operation, config = {}, logger, onRetry) {
|
|
374
|
+
const finalConfig = {
|
|
375
|
+
...DEFAULT_RETRY_CONFIG,
|
|
376
|
+
...config,
|
|
377
|
+
};
|
|
378
|
+
let lastError;
|
|
379
|
+
for (let attempt = 1; attempt <= finalConfig.maxAttempts; attempt++) {
|
|
380
|
+
try {
|
|
381
|
+
logger?.debug(`Attempt ${attempt}/${finalConfig.maxAttempts}`);
|
|
382
|
+
const result = await operation();
|
|
383
|
+
if (attempt > 1) {
|
|
384
|
+
logger?.info(`Operation succeeded on attempt ${attempt}`);
|
|
385
|
+
}
|
|
386
|
+
return result;
|
|
387
|
+
}
|
|
388
|
+
catch (error) {
|
|
389
|
+
lastError = error;
|
|
390
|
+
logger?.warn(`Attempt ${attempt} failed:`, error.message);
|
|
391
|
+
// Check if this is the last attempt
|
|
392
|
+
if (attempt === finalConfig.maxAttempts) {
|
|
393
|
+
logger?.error(`All ${finalConfig.maxAttempts} attempts failed. Giving up.`);
|
|
394
|
+
break;
|
|
395
|
+
}
|
|
396
|
+
// Check if error is retryable
|
|
397
|
+
if (!isRetryableError(error)) {
|
|
398
|
+
logger?.info('Error is not retryable. Stopping retry attempts.');
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
// Calculate delay for next attempt
|
|
402
|
+
const delay = calculateDelay(attempt, finalConfig);
|
|
403
|
+
logger?.debug(`Waiting ${delay}ms before next attempt...`);
|
|
404
|
+
// Call retry callback if provided
|
|
405
|
+
if (onRetry) {
|
|
406
|
+
try {
|
|
407
|
+
onRetry(attempt, finalConfig.maxAttempts, error);
|
|
408
|
+
}
|
|
409
|
+
catch (callbackError) {
|
|
410
|
+
logger?.warn('Retry callback threw an error:', callbackError);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// Wait before next attempt
|
|
414
|
+
await sleep(delay);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
// All attempts failed
|
|
418
|
+
throw lastError;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Create a retry-enabled version of a function
|
|
422
|
+
*/
|
|
423
|
+
function createRetryableFunction(fn, config = {}, logger) {
|
|
424
|
+
return async (...args) => {
|
|
425
|
+
return withRetry(() => fn(...args), config, logger);
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Smart retry decorator that can be used with different error types
|
|
430
|
+
*/
|
|
431
|
+
class RetryManager {
|
|
432
|
+
constructor(config = {}, logger) {
|
|
433
|
+
this.config = { ...DEFAULT_RETRY_CONFIG, ...config };
|
|
434
|
+
this.logger = logger;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Execute operation with retry logic
|
|
438
|
+
*/
|
|
439
|
+
async execute(operation, onRetry) {
|
|
440
|
+
return withRetry(operation, this.config, this.logger, onRetry);
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Update retry configuration
|
|
444
|
+
*/
|
|
445
|
+
updateConfig(newConfig) {
|
|
446
|
+
this.config = { ...this.config, ...newConfig };
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Get current configuration
|
|
450
|
+
*/
|
|
451
|
+
getConfig() {
|
|
452
|
+
return { ...this.config };
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Check if an attempt should be retried based on attempt number and error
|
|
456
|
+
*/
|
|
457
|
+
shouldRetry(attempt, error) {
|
|
458
|
+
if (attempt >= this.config.maxAttempts) {
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
return isRetryableError(error);
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Calculate the delay for a specific attempt
|
|
465
|
+
*/
|
|
466
|
+
getDelay(attempt) {
|
|
467
|
+
return calculateDelay(attempt, this.config);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Utility to create common retry configurations
|
|
472
|
+
*/
|
|
473
|
+
const RetryPresets = {
|
|
474
|
+
/**
|
|
475
|
+
* Quick retry for fast operations
|
|
476
|
+
*/
|
|
477
|
+
quick: () => ({
|
|
478
|
+
maxAttempts: 2,
|
|
479
|
+
baseDelay: 500,
|
|
480
|
+
maxDelay: 2000,
|
|
481
|
+
enableJitter: true,
|
|
482
|
+
backoffMultiplier: 2,
|
|
483
|
+
}),
|
|
484
|
+
/**
|
|
485
|
+
* Standard retry for most operations
|
|
486
|
+
*/
|
|
487
|
+
standard: () => ({
|
|
488
|
+
...DEFAULT_RETRY_CONFIG,
|
|
489
|
+
}),
|
|
490
|
+
/**
|
|
491
|
+
* Patient retry for heavy operations
|
|
492
|
+
*/
|
|
493
|
+
patient: () => ({
|
|
494
|
+
maxAttempts: 5,
|
|
495
|
+
baseDelay: 2000,
|
|
496
|
+
maxDelay: 60000,
|
|
497
|
+
enableJitter: true,
|
|
498
|
+
backoffMultiplier: 1.5,
|
|
499
|
+
}),
|
|
500
|
+
/**
|
|
501
|
+
* Aggressive retry for critical operations
|
|
502
|
+
*/
|
|
503
|
+
aggressive: () => ({
|
|
504
|
+
maxAttempts: 10,
|
|
505
|
+
baseDelay: 100,
|
|
506
|
+
maxDelay: 30000,
|
|
507
|
+
enableJitter: true,
|
|
508
|
+
backoffMultiplier: 1.8,
|
|
509
|
+
}),
|
|
510
|
+
/**
|
|
511
|
+
* Custom retry configuration
|
|
512
|
+
*/
|
|
513
|
+
custom: (overrides) => ({
|
|
514
|
+
...DEFAULT_RETRY_CONFIG,
|
|
515
|
+
...overrides,
|
|
516
|
+
}),
|
|
517
|
+
};
|
|
518
|
+
/**
|
|
519
|
+
* Utility function to get delay information for display purposes
|
|
520
|
+
*/
|
|
521
|
+
function getRetryDelayInfo(attempt, config = {}) {
|
|
522
|
+
const finalConfig = {
|
|
523
|
+
...DEFAULT_RETRY_CONFIG,
|
|
524
|
+
...config,
|
|
525
|
+
};
|
|
526
|
+
let totalElapsed = 0;
|
|
527
|
+
for (let i = 1; i < attempt; i++) {
|
|
528
|
+
totalElapsed += calculateDelay(i, finalConfig);
|
|
529
|
+
}
|
|
530
|
+
const delay = calculateDelay(attempt, finalConfig);
|
|
531
|
+
return { delay, totalElapsed };
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* File handling and validation utilities
|
|
536
|
+
*/
|
|
537
|
+
/**
|
|
538
|
+
* Validate file against configuration rules
|
|
539
|
+
*/
|
|
540
|
+
function validateFile(file, config) {
|
|
541
|
+
// Check file size
|
|
542
|
+
if (file.size > config.maxFileSize) {
|
|
543
|
+
return `File "${file.name}" is too large. Maximum size is ${formatFileSize(config.maxFileSize)}, but file is ${formatFileSize(file.size)}.`;
|
|
544
|
+
}
|
|
545
|
+
// Check file type if restrictions are set
|
|
546
|
+
if (config.allowedTypes.length > 0) {
|
|
547
|
+
const isAllowed = config.allowedTypes.some(allowedType => {
|
|
548
|
+
// Handle wildcard types like "image/*"
|
|
549
|
+
if (allowedType.includes('*')) {
|
|
550
|
+
const baseType = allowedType.split('/')[0];
|
|
551
|
+
return file.type.startsWith(baseType + '/');
|
|
552
|
+
}
|
|
553
|
+
// Handle exact type matches
|
|
554
|
+
return file.type === allowedType;
|
|
555
|
+
});
|
|
556
|
+
if (!isAllowed) {
|
|
557
|
+
return `File "${file.name}" has unsupported type "${file.type}". Allowed types: ${config.allowedTypes.join(', ')}.`;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return null; // File is valid
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Format file size for human-readable display
|
|
564
|
+
*/
|
|
565
|
+
function formatFileSize(bytes) {
|
|
566
|
+
if (bytes === 0)
|
|
567
|
+
return '0 Bytes';
|
|
568
|
+
const k = 1024;
|
|
569
|
+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
|
570
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
571
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Extract files from form data object
|
|
575
|
+
*/
|
|
576
|
+
function extractFiles(data) {
|
|
577
|
+
const files = [];
|
|
578
|
+
function processValue(key, value) {
|
|
579
|
+
if (value instanceof File) {
|
|
580
|
+
files.push({
|
|
581
|
+
field: key,
|
|
582
|
+
file: value,
|
|
583
|
+
name: value.name,
|
|
584
|
+
size: value.size,
|
|
585
|
+
type: value.type,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
else if (value instanceof FileList) {
|
|
589
|
+
// Handle multiple files from input[type="file"][multiple]
|
|
590
|
+
for (let i = 0; i < value.length; i++) {
|
|
591
|
+
const file = value.item(i);
|
|
592
|
+
if (file) {
|
|
593
|
+
files.push({
|
|
594
|
+
field: `${key}[${i}]`,
|
|
595
|
+
file,
|
|
596
|
+
name: file.name,
|
|
597
|
+
size: file.size,
|
|
598
|
+
type: file.type,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
else if (Array.isArray(value)) {
|
|
604
|
+
// Handle array of files
|
|
605
|
+
value.forEach((item, index) => {
|
|
606
|
+
if (item instanceof File) {
|
|
607
|
+
files.push({
|
|
608
|
+
field: `${key}[${index}]`,
|
|
609
|
+
file: item,
|
|
610
|
+
name: item.name,
|
|
611
|
+
size: item.size,
|
|
612
|
+
type: item.type,
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// Process all form fields
|
|
619
|
+
for (const [key, value] of Object.entries(data)) {
|
|
620
|
+
processValue(key, value);
|
|
621
|
+
}
|
|
622
|
+
return files;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Validate all files in a collection
|
|
626
|
+
*/
|
|
627
|
+
function validateFiles(files, config, logger) {
|
|
628
|
+
const errors = [];
|
|
629
|
+
for (const fileInfo of files) {
|
|
630
|
+
logger?.debug(`Validating file: ${fileInfo.name} (${formatFileSize(fileInfo.size)}, ${fileInfo.type})`);
|
|
631
|
+
const error = validateFile(fileInfo.file, config);
|
|
632
|
+
if (error) {
|
|
633
|
+
errors.push(error);
|
|
634
|
+
logger?.warn(`File validation failed: ${error}`);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return errors;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Process form data and handle files
|
|
641
|
+
*/
|
|
642
|
+
function processFormData(inputData, fileConfig = {}, logger) {
|
|
643
|
+
const config = { ...DEFAULT_FILE_CONFIG, ...fileConfig };
|
|
644
|
+
// If input is already FormData, extract information from it
|
|
645
|
+
if (inputData instanceof FormData) {
|
|
646
|
+
const files = [];
|
|
647
|
+
// Process FormData entries
|
|
648
|
+
for (const [key, value] of inputData.entries()) {
|
|
649
|
+
if (value instanceof File) {
|
|
650
|
+
files.push({
|
|
651
|
+
field: key,
|
|
652
|
+
file: value,
|
|
653
|
+
name: value.name,
|
|
654
|
+
size: value.size,
|
|
655
|
+
type: value.type,
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// Validate files
|
|
660
|
+
const validationErrors = validateFiles(files, config, logger);
|
|
661
|
+
if (validationErrors.length > 0) {
|
|
662
|
+
const error = new Error(validationErrors.join(' '));
|
|
663
|
+
error.name = 'SubmitError';
|
|
664
|
+
error.code = ERROR_CODES.FILE_TOO_LARGE; // Could be file size or type error
|
|
665
|
+
error.attempt = 0;
|
|
666
|
+
error.isRetryable = false;
|
|
667
|
+
throw error;
|
|
668
|
+
}
|
|
669
|
+
return {
|
|
670
|
+
data: inputData,
|
|
671
|
+
hasFiles: files.length > 0,
|
|
672
|
+
files,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
// Handle object data
|
|
676
|
+
const files = extractFiles(inputData);
|
|
677
|
+
const hasFiles = files.length > 0;
|
|
678
|
+
logger?.debug(`Found ${files.length} files in form data`);
|
|
679
|
+
// Validate files
|
|
680
|
+
if (hasFiles) {
|
|
681
|
+
const validationErrors = validateFiles(files, config, logger);
|
|
682
|
+
if (validationErrors.length > 0) {
|
|
683
|
+
const error = new Error(validationErrors.join(' '));
|
|
684
|
+
error.name = 'SubmitError';
|
|
685
|
+
error.code = validationErrors[0]?.includes('too large')
|
|
686
|
+
? ERROR_CODES.FILE_TOO_LARGE
|
|
687
|
+
: ERROR_CODES.INVALID_FILE_TYPE;
|
|
688
|
+
error.attempt = 0;
|
|
689
|
+
error.isRetryable = false;
|
|
690
|
+
throw error;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
// Convert to FormData if files are present
|
|
694
|
+
let processedData;
|
|
695
|
+
if (hasFiles) {
|
|
696
|
+
logger?.debug('Converting to FormData due to file presence');
|
|
697
|
+
processedData = new FormData();
|
|
698
|
+
// Add all non-file fields
|
|
699
|
+
for (const [key, value] of Object.entries(inputData)) {
|
|
700
|
+
if (!(value instanceof File) && !(value instanceof FileList) && !Array.isArray(value)) {
|
|
701
|
+
// Handle simple values
|
|
702
|
+
if (value !== undefined && value !== null) {
|
|
703
|
+
processedData.append(key, String(value));
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
else if (Array.isArray(value)) {
|
|
707
|
+
// Handle arrays (might contain files or other values)
|
|
708
|
+
value.forEach((item, index) => {
|
|
709
|
+
if (item instanceof File) {
|
|
710
|
+
processedData.append(`${key}[${index}]`, item);
|
|
711
|
+
}
|
|
712
|
+
else if (item !== undefined && item !== null) {
|
|
713
|
+
processedData.append(`${key}[${index}]`, String(item));
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
else if (value instanceof File) {
|
|
718
|
+
processedData.append(key, value);
|
|
719
|
+
}
|
|
720
|
+
else if (value instanceof FileList) {
|
|
721
|
+
for (let i = 0; i < value.length; i++) {
|
|
722
|
+
const file = value.item(i);
|
|
723
|
+
if (file) {
|
|
724
|
+
processedData.append(`${key}[${i}]`, file);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
else {
|
|
731
|
+
// No files, keep as object (will be sent as JSON)
|
|
732
|
+
logger?.debug('No files found, keeping as object for JSON submission');
|
|
733
|
+
processedData = { ...inputData };
|
|
734
|
+
}
|
|
735
|
+
return {
|
|
736
|
+
data: processedData,
|
|
737
|
+
hasFiles,
|
|
738
|
+
files,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Create progress tracker for file uploads
|
|
743
|
+
*/
|
|
744
|
+
function createProgressTracker(files, onProgress) {
|
|
745
|
+
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
|
|
746
|
+
if (!onProgress || totalSize === 0) {
|
|
747
|
+
return undefined;
|
|
748
|
+
}
|
|
749
|
+
return (progressEvent) => {
|
|
750
|
+
if (progressEvent.lengthComputable) {
|
|
751
|
+
const percent = Math.round((progressEvent.loaded / progressEvent.total) * 100);
|
|
752
|
+
onProgress(percent, progressEvent.loaded, progressEvent.total);
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Get MIME type from file extension (fallback if browser doesn't detect)
|
|
758
|
+
*/
|
|
759
|
+
function getMimeTypeFromExtension(filename) {
|
|
760
|
+
const extension = filename.split('.').pop()?.toLowerCase();
|
|
761
|
+
const mimeTypes = {
|
|
762
|
+
// Images
|
|
763
|
+
jpg: 'image/jpeg',
|
|
764
|
+
jpeg: 'image/jpeg',
|
|
765
|
+
png: 'image/png',
|
|
766
|
+
gif: 'image/gif',
|
|
767
|
+
webp: 'image/webp',
|
|
768
|
+
svg: 'image/svg+xml',
|
|
769
|
+
// Documents
|
|
770
|
+
pdf: 'application/pdf',
|
|
771
|
+
doc: 'application/msword',
|
|
772
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
773
|
+
xls: 'application/vnd.ms-excel',
|
|
774
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
775
|
+
ppt: 'application/vnd.ms-powerpoint',
|
|
776
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
777
|
+
// Text
|
|
778
|
+
txt: 'text/plain',
|
|
779
|
+
csv: 'text/csv',
|
|
780
|
+
json: 'application/json',
|
|
781
|
+
xml: 'application/xml',
|
|
782
|
+
// Archives
|
|
783
|
+
zip: 'application/zip',
|
|
784
|
+
rar: 'application/x-rar-compressed',
|
|
785
|
+
'7z': 'application/x-7z-compressed',
|
|
786
|
+
// Audio
|
|
787
|
+
mp3: 'audio/mpeg',
|
|
788
|
+
wav: 'audio/wav',
|
|
789
|
+
ogg: 'audio/ogg',
|
|
790
|
+
// Video
|
|
791
|
+
mp4: 'video/mp4',
|
|
792
|
+
avi: 'video/x-msvideo',
|
|
793
|
+
mov: 'video/quicktime',
|
|
794
|
+
wmv: 'video/x-ms-wmv',
|
|
795
|
+
};
|
|
796
|
+
return extension ? mimeTypes[extension] || null : null;
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Enhanced file information with additional metadata
|
|
800
|
+
*/
|
|
801
|
+
function getEnhancedFileInfo(file) {
|
|
802
|
+
const extension = file.name.split('.').pop()?.toLowerCase() || '';
|
|
803
|
+
const detectedMimeType = getMimeTypeFromExtension(file.name);
|
|
804
|
+
return {
|
|
805
|
+
field: '', // Will be set by caller
|
|
806
|
+
file,
|
|
807
|
+
name: file.name,
|
|
808
|
+
size: file.size,
|
|
809
|
+
type: file.type,
|
|
810
|
+
extension,
|
|
811
|
+
detectedMimeType,
|
|
812
|
+
isImage: file.type.startsWith('image/'),
|
|
813
|
+
isDocument: file.type.includes('pdf') || file.type.includes('document') || file.type.includes('word') || file.type.includes('excel') || file.type.includes('powerpoint'),
|
|
814
|
+
isArchive: file.type.includes('zip') || file.type.includes('rar') || file.type.includes('7z'),
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Utility functions and logging
|
|
820
|
+
*/
|
|
821
|
+
/**
|
|
822
|
+
* Simple console-based logger implementation
|
|
823
|
+
*/
|
|
824
|
+
class ConsoleLogger {
|
|
825
|
+
constructor(enabled = false) {
|
|
826
|
+
this.enabled = enabled;
|
|
827
|
+
}
|
|
828
|
+
debug(message, ...args) {
|
|
829
|
+
if (this.enabled) {
|
|
830
|
+
console.debug('[FormshiveSubmit DEBUG]', message, ...args);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
info(message, ...args) {
|
|
834
|
+
if (this.enabled) {
|
|
835
|
+
console.info('[FormshiveSubmit INFO]', message, ...args);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
warn(message, ...args) {
|
|
839
|
+
if (this.enabled) {
|
|
840
|
+
console.warn('[FormshiveSubmit WARN]', message, ...args);
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
error(message, ...args) {
|
|
844
|
+
if (this.enabled) {
|
|
845
|
+
console.error('[FormshiveSubmit ERROR]', message, ...args);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
setEnabled(enabled) {
|
|
849
|
+
this.enabled = enabled;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* No-op logger for production builds
|
|
854
|
+
*/
|
|
855
|
+
class NoOpLogger {
|
|
856
|
+
debug() { }
|
|
857
|
+
info() { }
|
|
858
|
+
warn() { }
|
|
859
|
+
error() { }
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* Create logger based on debug flag
|
|
863
|
+
*/
|
|
864
|
+
function createLogger(debug = false) {
|
|
865
|
+
return debug ? new ConsoleLogger(true) : new NoOpLogger();
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Validate URL format
|
|
869
|
+
*/
|
|
870
|
+
function isValidUrl(url) {
|
|
871
|
+
try {
|
|
872
|
+
new URL(url);
|
|
873
|
+
return true;
|
|
874
|
+
}
|
|
875
|
+
catch {
|
|
876
|
+
return false;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Ensure URL has protocol
|
|
881
|
+
*/
|
|
882
|
+
function ensureProtocol(url) {
|
|
883
|
+
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
884
|
+
return `https://${url}`;
|
|
885
|
+
}
|
|
886
|
+
return url;
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Build form submission URL
|
|
890
|
+
*/
|
|
891
|
+
function buildSubmissionUrl(endpoint, formId) {
|
|
892
|
+
const baseUrl = ensureProtocol(endpoint);
|
|
893
|
+
const trimmedUrl = baseUrl.replace(/\/$/, '');
|
|
894
|
+
return `${trimmedUrl}/digest/${encodeURIComponent(formId)}`;
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Generate unique request ID for tracking
|
|
898
|
+
*/
|
|
899
|
+
function generateRequestId() {
|
|
900
|
+
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Format duration in milliseconds to human readable string
|
|
904
|
+
*/
|
|
905
|
+
function formatDuration(ms) {
|
|
906
|
+
if (ms < 1000) {
|
|
907
|
+
return `${ms}ms`;
|
|
908
|
+
}
|
|
909
|
+
else if (ms < 60000) {
|
|
910
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
911
|
+
}
|
|
912
|
+
else {
|
|
913
|
+
const minutes = Math.floor(ms / 60000);
|
|
914
|
+
const seconds = ((ms % 60000) / 1000).toFixed(0);
|
|
915
|
+
return `${minutes}m ${seconds}s`;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Deep clone object (simple implementation)
|
|
920
|
+
*/
|
|
921
|
+
function deepClone(obj) {
|
|
922
|
+
if (obj === null || typeof obj !== 'object') {
|
|
923
|
+
return obj;
|
|
924
|
+
}
|
|
925
|
+
if (obj instanceof Date) {
|
|
926
|
+
return new Date(obj.getTime());
|
|
927
|
+
}
|
|
928
|
+
if (obj instanceof Array) {
|
|
929
|
+
return obj.map(item => deepClone(item));
|
|
930
|
+
}
|
|
931
|
+
if (typeof obj === 'object') {
|
|
932
|
+
const cloned = {};
|
|
933
|
+
for (const key in obj) {
|
|
934
|
+
if (obj.hasOwnProperty(key)) {
|
|
935
|
+
cloned[key] = deepClone(obj[key]);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return cloned;
|
|
939
|
+
}
|
|
940
|
+
return obj;
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Safely get nested object property
|
|
944
|
+
*/
|
|
945
|
+
function getNestedProperty(obj, path) {
|
|
946
|
+
return path.split('.').reduce((current, key) => {
|
|
947
|
+
return current && current[key] !== undefined ? current[key] : undefined;
|
|
948
|
+
}, obj);
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Debounce function
|
|
952
|
+
*/
|
|
953
|
+
function debounce(func, wait) {
|
|
954
|
+
let timeout = null;
|
|
955
|
+
return (...args) => {
|
|
956
|
+
if (timeout) {
|
|
957
|
+
clearTimeout(timeout);
|
|
958
|
+
}
|
|
959
|
+
timeout = setTimeout(() => func(...args), wait);
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Throttle function
|
|
964
|
+
*/
|
|
965
|
+
function throttle(func, limit) {
|
|
966
|
+
let inThrottle = false;
|
|
967
|
+
return (...args) => {
|
|
968
|
+
if (!inThrottle) {
|
|
969
|
+
func(...args);
|
|
970
|
+
inThrottle = true;
|
|
971
|
+
setTimeout(() => (inThrottle = false), limit);
|
|
972
|
+
}
|
|
973
|
+
};
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Check if running in browser environment
|
|
977
|
+
*/
|
|
978
|
+
function isBrowser() {
|
|
979
|
+
return typeof window !== 'undefined' && typeof document !== 'undefined';
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Check if fetch is available
|
|
983
|
+
*/
|
|
984
|
+
function isFetchAvailable() {
|
|
985
|
+
return typeof fetch !== 'undefined';
|
|
986
|
+
}
|
|
987
|
+
/**
|
|
988
|
+
* Check if FormData is available
|
|
989
|
+
*/
|
|
990
|
+
function isFormDataAvailable() {
|
|
991
|
+
return typeof FormData !== 'undefined';
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Get environment info for debugging
|
|
995
|
+
*/
|
|
996
|
+
function getEnvironmentInfo() {
|
|
997
|
+
const userAgent = isBrowser() ? navigator.userAgent : undefined;
|
|
998
|
+
return {
|
|
999
|
+
browser: isBrowser(),
|
|
1000
|
+
fetch: isFetchAvailable(),
|
|
1001
|
+
formData: isFormDataAvailable(),
|
|
1002
|
+
userAgent,
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Parse error message from various error types
|
|
1007
|
+
*/
|
|
1008
|
+
function parseErrorMessage(error) {
|
|
1009
|
+
if (typeof error === 'string') {
|
|
1010
|
+
return error;
|
|
1011
|
+
}
|
|
1012
|
+
if (error && typeof error === 'object') {
|
|
1013
|
+
// Check for common error properties
|
|
1014
|
+
if (error.message) {
|
|
1015
|
+
return String(error.message);
|
|
1016
|
+
}
|
|
1017
|
+
if (error.error) {
|
|
1018
|
+
return String(error.error);
|
|
1019
|
+
}
|
|
1020
|
+
if (error.statusText) {
|
|
1021
|
+
return String(error.statusText);
|
|
1022
|
+
}
|
|
1023
|
+
// Try to stringify if it's an object
|
|
1024
|
+
try {
|
|
1025
|
+
return JSON.stringify(error);
|
|
1026
|
+
}
|
|
1027
|
+
catch {
|
|
1028
|
+
return 'Unknown error';
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
return 'Unknown error';
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Sanitize string for logging (remove sensitive information)
|
|
1035
|
+
*/
|
|
1036
|
+
function sanitizeForLogging(input) {
|
|
1037
|
+
// Remove potential passwords, tokens, keys, etc.
|
|
1038
|
+
return input
|
|
1039
|
+
.replace(/password[^&\s]*=[^&\s]*/gi, 'password=***')
|
|
1040
|
+
.replace(/token[^&\s]*=[^&\s]*/gi, 'token=***')
|
|
1041
|
+
.replace(/key[^&\s]*=[^&\s]*/gi, 'key=***')
|
|
1042
|
+
.replace(/secret[^&\s]*=[^&\s]*/gi, 'secret=***')
|
|
1043
|
+
.replace(/authorization:\s*[^\s]+/gi, 'authorization: ***');
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Create a timeout promise that rejects after specified time
|
|
1047
|
+
*/
|
|
1048
|
+
function createTimeoutPromise(ms) {
|
|
1049
|
+
return new Promise((_, reject) => {
|
|
1050
|
+
setTimeout(() => {
|
|
1051
|
+
reject(new Error(`Operation timed out after ${ms}ms`));
|
|
1052
|
+
}, ms);
|
|
1053
|
+
});
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Race a promise against a timeout
|
|
1057
|
+
*/
|
|
1058
|
+
function withTimeout(promise, ms) {
|
|
1059
|
+
return Promise.race([
|
|
1060
|
+
promise,
|
|
1061
|
+
createTimeoutPromise(ms)
|
|
1062
|
+
]);
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Retry a promise with simple linear backoff
|
|
1066
|
+
*/
|
|
1067
|
+
async function simpleRetry(fn, maxRetries = 3, delay = 1000) {
|
|
1068
|
+
let lastError;
|
|
1069
|
+
for (let i = 0; i <= maxRetries; i++) {
|
|
1070
|
+
try {
|
|
1071
|
+
return await fn();
|
|
1072
|
+
}
|
|
1073
|
+
catch (error) {
|
|
1074
|
+
lastError = error;
|
|
1075
|
+
if (i < maxRetries) {
|
|
1076
|
+
await new Promise(resolve => setTimeout(resolve, delay * (i + 1)));
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
throw lastError;
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Convert object to query string
|
|
1084
|
+
*/
|
|
1085
|
+
function objectToQueryString(obj) {
|
|
1086
|
+
const params = new URLSearchParams();
|
|
1087
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
1088
|
+
if (value !== undefined && value !== null) {
|
|
1089
|
+
params.append(key, String(value));
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return params.toString();
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* Merge objects with deep merging for nested objects
|
|
1096
|
+
*/
|
|
1097
|
+
function mergeObjects(target, ...sources) {
|
|
1098
|
+
if (!sources.length)
|
|
1099
|
+
return target;
|
|
1100
|
+
const source = sources.shift();
|
|
1101
|
+
if (source) {
|
|
1102
|
+
for (const key in source) {
|
|
1103
|
+
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
|
|
1104
|
+
if (!target[key]) {
|
|
1105
|
+
Object.assign(target, { [key]: {} });
|
|
1106
|
+
}
|
|
1107
|
+
mergeObjects(target[key], source[key]);
|
|
1108
|
+
}
|
|
1109
|
+
else {
|
|
1110
|
+
Object.assign(target, { [key]: source[key] });
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
return mergeObjects(target, ...sources);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Main form submission functionality
|
|
1119
|
+
*/
|
|
1120
|
+
/**
|
|
1121
|
+
* Main form submission function
|
|
1122
|
+
*/
|
|
1123
|
+
async function submitForm(options) {
|
|
1124
|
+
// Validate required options
|
|
1125
|
+
if (!options.formId) {
|
|
1126
|
+
throw new Error('formId is required');
|
|
1127
|
+
}
|
|
1128
|
+
if (!options.data) {
|
|
1129
|
+
throw new Error('data is required');
|
|
1130
|
+
}
|
|
1131
|
+
// Merge with defaults
|
|
1132
|
+
const config = { ...DEFAULT_OPTIONS, ...options };
|
|
1133
|
+
// Create logger
|
|
1134
|
+
const logger = createLogger(config.debug);
|
|
1135
|
+
// Generate unique request ID for tracking
|
|
1136
|
+
const requestId = generateRequestId();
|
|
1137
|
+
const startTime = Date.now();
|
|
1138
|
+
logger.info(`Starting form submission (${requestId})`, {
|
|
1139
|
+
formId: config.formId,
|
|
1140
|
+
endpoint: config.endpoint,
|
|
1141
|
+
hasFiles: config.data instanceof FormData,
|
|
1142
|
+
environment: getEnvironmentInfo(),
|
|
1143
|
+
});
|
|
1144
|
+
// Validate endpoint URL
|
|
1145
|
+
if (!isValidUrl(config.endpoint)) {
|
|
1146
|
+
const error = new Error(`Invalid endpoint URL: ${config.endpoint}`);
|
|
1147
|
+
error.name = 'SubmitError';
|
|
1148
|
+
error.code = ERROR_CODES.VALIDATION_ERROR;
|
|
1149
|
+
error.attempt = 0;
|
|
1150
|
+
error.isRetryable = false;
|
|
1151
|
+
throw error;
|
|
1152
|
+
}
|
|
1153
|
+
try {
|
|
1154
|
+
// Create HTTP client
|
|
1155
|
+
const httpClient = createHttpClient(config.httpClient);
|
|
1156
|
+
logger.debug('HTTP client created', {
|
|
1157
|
+
type: typeof config.httpClient === 'string' ? config.httpClient : 'custom',
|
|
1158
|
+
supportsProgress: httpClient.supportsProgress(),
|
|
1159
|
+
});
|
|
1160
|
+
// Process form data and handle files
|
|
1161
|
+
const processedData = processFormData(config.data, config.files, logger);
|
|
1162
|
+
logger.debug('Form data processed', {
|
|
1163
|
+
hasFiles: processedData.hasFiles,
|
|
1164
|
+
fileCount: processedData.files.length,
|
|
1165
|
+
totalFileSize: processedData.files.reduce((sum, f) => sum + f.size, 0),
|
|
1166
|
+
});
|
|
1167
|
+
// Build submission URL
|
|
1168
|
+
const submissionUrl = buildSubmissionUrl(config.endpoint, config.formId);
|
|
1169
|
+
logger.debug(`Submission URL: ${submissionUrl}`);
|
|
1170
|
+
// Prepare request configuration
|
|
1171
|
+
const requestConfig = {
|
|
1172
|
+
url: submissionUrl,
|
|
1173
|
+
method: 'POST',
|
|
1174
|
+
data: processedData.data,
|
|
1175
|
+
headers: {
|
|
1176
|
+
...config.headers,
|
|
1177
|
+
},
|
|
1178
|
+
timeout: config.timeout || 30000,
|
|
1179
|
+
};
|
|
1180
|
+
// Set appropriate content type if not manually specified
|
|
1181
|
+
if (!requestConfig.headers['Content-Type'] && !processedData.hasFiles) {
|
|
1182
|
+
requestConfig.headers['Content-Type'] = 'application/json';
|
|
1183
|
+
// Convert object to JSON string for non-multipart requests
|
|
1184
|
+
if (!(processedData.data instanceof FormData)) {
|
|
1185
|
+
requestConfig.data = JSON.stringify(processedData.data);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
// Set up progress tracking for file uploads
|
|
1189
|
+
if (processedData.hasFiles && httpClient.supportsProgress() && config.callbacks?.onProgress) {
|
|
1190
|
+
const progressTracker = createProgressTracker(processedData.files, config.callbacks.onProgress);
|
|
1191
|
+
if (progressTracker) {
|
|
1192
|
+
requestConfig.onUploadProgress = progressTracker;
|
|
1193
|
+
logger.debug('Progress tracking enabled for file upload');
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
// Call onStart callback
|
|
1197
|
+
if (config.callbacks?.onStart) {
|
|
1198
|
+
try {
|
|
1199
|
+
config.callbacks.onStart();
|
|
1200
|
+
}
|
|
1201
|
+
catch (callbackError) {
|
|
1202
|
+
logger.warn('onStart callback error:', callbackError);
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
// Create the submission function with retry logic
|
|
1206
|
+
const submitWithRetry = async () => {
|
|
1207
|
+
return withRetry(async () => {
|
|
1208
|
+
logger.debug('Making HTTP request...');
|
|
1209
|
+
const response = await httpClient.request(requestConfig);
|
|
1210
|
+
const duration = Date.now() - startTime;
|
|
1211
|
+
logger.info(`Form submission successful (${requestId})`, {
|
|
1212
|
+
statusCode: response.status,
|
|
1213
|
+
duration: formatDuration(duration),
|
|
1214
|
+
});
|
|
1215
|
+
// Prepare successful response
|
|
1216
|
+
const submitResponse = {
|
|
1217
|
+
success: true,
|
|
1218
|
+
data: response.data,
|
|
1219
|
+
statusCode: response.status,
|
|
1220
|
+
headers: response.headers,
|
|
1221
|
+
attempt: 1, // Will be updated by retry logic
|
|
1222
|
+
duration,
|
|
1223
|
+
};
|
|
1224
|
+
// Check for redirect URL in response
|
|
1225
|
+
if (response.headers['location']) {
|
|
1226
|
+
submitResponse.redirectUrl = response.headers['location'];
|
|
1227
|
+
}
|
|
1228
|
+
else if (response.data && typeof response.data === 'object' && response.data.redirect_url) {
|
|
1229
|
+
submitResponse.redirectUrl = response.data.redirect_url;
|
|
1230
|
+
}
|
|
1231
|
+
return submitResponse;
|
|
1232
|
+
}, config.retry, logger, (attempt, maxAttempts, error) => {
|
|
1233
|
+
logger.warn(`Retry attempt ${attempt}/${maxAttempts}:`, error.message);
|
|
1234
|
+
// Call retry callback
|
|
1235
|
+
if (config.callbacks?.onRetry) {
|
|
1236
|
+
try {
|
|
1237
|
+
config.callbacks.onRetry(attempt, maxAttempts, error);
|
|
1238
|
+
}
|
|
1239
|
+
catch (callbackError) {
|
|
1240
|
+
logger.warn('onRetry callback error:', callbackError);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
});
|
|
1244
|
+
};
|
|
1245
|
+
// Execute submission with retry
|
|
1246
|
+
const result = await submitWithRetry();
|
|
1247
|
+
// Update attempt count (this would be set by the retry mechanism)
|
|
1248
|
+
// For now, we'll estimate based on timing or assume success on attempt 1
|
|
1249
|
+
result.attempt = 1; // This should be provided by the retry mechanism
|
|
1250
|
+
// Call success callback
|
|
1251
|
+
if (config.callbacks?.onSuccess) {
|
|
1252
|
+
try {
|
|
1253
|
+
config.callbacks.onSuccess(result);
|
|
1254
|
+
}
|
|
1255
|
+
catch (callbackError) {
|
|
1256
|
+
logger.warn('onSuccess callback error:', callbackError);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
return result;
|
|
1260
|
+
}
|
|
1261
|
+
catch (error) {
|
|
1262
|
+
const duration = Date.now() - startTime;
|
|
1263
|
+
// Convert error to SubmitError if it isn't already
|
|
1264
|
+
let submitError;
|
|
1265
|
+
if (error.name === 'SubmitError') {
|
|
1266
|
+
submitError = error;
|
|
1267
|
+
}
|
|
1268
|
+
else {
|
|
1269
|
+
submitError = createSubmitError(error, 1); // Default to attempt 1
|
|
1270
|
+
}
|
|
1271
|
+
// Add duration and request ID for debugging
|
|
1272
|
+
submitError.message = `${submitError.message} (${requestId}, ${formatDuration(duration)})`;
|
|
1273
|
+
logger.error(`Form submission failed (${requestId})`, {
|
|
1274
|
+
error: submitError.code,
|
|
1275
|
+
message: submitError.message,
|
|
1276
|
+
statusCode: submitError.statusCode,
|
|
1277
|
+
duration: formatDuration(duration),
|
|
1278
|
+
isRetryable: submitError.isRetryable,
|
|
1279
|
+
});
|
|
1280
|
+
// Call error callback
|
|
1281
|
+
if (config.callbacks?.onError) {
|
|
1282
|
+
try {
|
|
1283
|
+
config.callbacks.onError(submitError);
|
|
1284
|
+
}
|
|
1285
|
+
catch (callbackError) {
|
|
1286
|
+
logger.warn('onError callback error:', callbackError);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
throw submitError;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Simplified form submission function with common defaults
|
|
1294
|
+
*/
|
|
1295
|
+
async function submitFormSimple(formId, data, options = {}) {
|
|
1296
|
+
return submitForm({
|
|
1297
|
+
formId,
|
|
1298
|
+
data,
|
|
1299
|
+
...options,
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
/**
|
|
1303
|
+
* Submit form with success/error callbacks in a more functional style
|
|
1304
|
+
*/
|
|
1305
|
+
function submitFormWithCallbacks(options, onSuccess, onError) {
|
|
1306
|
+
const config = {
|
|
1307
|
+
...options,
|
|
1308
|
+
callbacks: {
|
|
1309
|
+
...options.callbacks,
|
|
1310
|
+
onSuccess: onSuccess || options.callbacks?.onSuccess,
|
|
1311
|
+
onError: onError || options.callbacks?.onError,
|
|
1312
|
+
},
|
|
1313
|
+
};
|
|
1314
|
+
return submitForm(config);
|
|
1315
|
+
}
|
|
1316
|
+
/**
|
|
1317
|
+
* Create a reusable form submitter with pre-configured options
|
|
1318
|
+
*/
|
|
1319
|
+
class FormSubmitter {
|
|
1320
|
+
constructor(defaultOptions = {}) {
|
|
1321
|
+
this.defaultOptions = defaultOptions;
|
|
1322
|
+
this.logger = createLogger(defaultOptions.debug);
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* Submit a form using the default configuration
|
|
1326
|
+
*/
|
|
1327
|
+
async submit(formId, data, overrideOptions = {}) {
|
|
1328
|
+
const baseOptions = { formId, data };
|
|
1329
|
+
const options = {
|
|
1330
|
+
...this.defaultOptions,
|
|
1331
|
+
...baseOptions,
|
|
1332
|
+
...overrideOptions
|
|
1333
|
+
};
|
|
1334
|
+
return submitForm(options);
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Update default options
|
|
1338
|
+
*/
|
|
1339
|
+
updateDefaults(newDefaults) {
|
|
1340
|
+
this.defaultOptions = { ...this.defaultOptions, ...newDefaults };
|
|
1341
|
+
this.logger = createLogger(this.defaultOptions.debug || false);
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Get current default options
|
|
1345
|
+
*/
|
|
1346
|
+
getDefaults() {
|
|
1347
|
+
return { ...this.defaultOptions };
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Test connection to the API endpoint
|
|
1351
|
+
*/
|
|
1352
|
+
async testConnection(endpoint) {
|
|
1353
|
+
const testEndpoint = endpoint || this.defaultOptions.endpoint || DEFAULT_OPTIONS.endpoint;
|
|
1354
|
+
try {
|
|
1355
|
+
const httpClient = createHttpClient(this.defaultOptions.httpClient || 'fetch');
|
|
1356
|
+
// Try to make a simple request to the base endpoint
|
|
1357
|
+
await httpClient.request({
|
|
1358
|
+
url: testEndpoint,
|
|
1359
|
+
method: 'GET',
|
|
1360
|
+
timeout: 5000,
|
|
1361
|
+
});
|
|
1362
|
+
return true;
|
|
1363
|
+
}
|
|
1364
|
+
catch (error) {
|
|
1365
|
+
this.logger.debug('Connection test failed:', error);
|
|
1366
|
+
return false;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* Utility to validate form data before submission
|
|
1372
|
+
*/
|
|
1373
|
+
function validateFormData(data) {
|
|
1374
|
+
const errors = [];
|
|
1375
|
+
const warnings = [];
|
|
1376
|
+
if (!data) {
|
|
1377
|
+
errors.push('Form data is required');
|
|
1378
|
+
return { isValid: false, errors, warnings };
|
|
1379
|
+
}
|
|
1380
|
+
// Check for common issues
|
|
1381
|
+
if (data instanceof FormData) {
|
|
1382
|
+
let hasData = false;
|
|
1383
|
+
for (const [key, value] of data.entries()) {
|
|
1384
|
+
hasData = true;
|
|
1385
|
+
if (typeof value === 'string' && value.trim() === '') {
|
|
1386
|
+
warnings.push(`Field '${key}' is empty`);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
if (!hasData) {
|
|
1390
|
+
errors.push('FormData contains no fields');
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
else {
|
|
1394
|
+
const keys = Object.keys(data);
|
|
1395
|
+
if (keys.length === 0) {
|
|
1396
|
+
errors.push('Form data object is empty');
|
|
1397
|
+
}
|
|
1398
|
+
// Check for potential issues
|
|
1399
|
+
keys.forEach(key => {
|
|
1400
|
+
const value = data[key];
|
|
1401
|
+
if (value === null || value === undefined) {
|
|
1402
|
+
warnings.push(`Field '${key}' is null or undefined`);
|
|
1403
|
+
}
|
|
1404
|
+
else if (typeof value === 'string' && value.trim() === '') {
|
|
1405
|
+
warnings.push(`Field '${key}' is empty`);
|
|
1406
|
+
}
|
|
1407
|
+
else if (typeof value === 'object' && !(value instanceof File) && !(value instanceof FileList)) {
|
|
1408
|
+
warnings.push(`Field '${key}' contains complex object - may not be serialized correctly`);
|
|
1409
|
+
}
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
return {
|
|
1413
|
+
isValid: errors.length === 0,
|
|
1414
|
+
errors,
|
|
1415
|
+
warnings,
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
/**
|
|
1420
|
+
* Field validation error handling utilities
|
|
1421
|
+
*/
|
|
1422
|
+
/**
|
|
1423
|
+
* Check if an error contains field validation errors
|
|
1424
|
+
*/
|
|
1425
|
+
function isFieldValidationError(error) {
|
|
1426
|
+
return !!(error.fieldErrors && error.fieldErrors.length > 0);
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* Get all field validation errors from a SubmitError
|
|
1430
|
+
*/
|
|
1431
|
+
function getFieldErrors(error) {
|
|
1432
|
+
return error.fieldErrors || [];
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* Get validation error for a specific field
|
|
1436
|
+
*/
|
|
1437
|
+
function getFieldError(error, fieldName) {
|
|
1438
|
+
const fieldErrors = getFieldErrors(error);
|
|
1439
|
+
return fieldErrors.find(err => err.field === fieldName) || null;
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* Check if a specific field has validation errors
|
|
1443
|
+
*/
|
|
1444
|
+
function hasFieldError(error, fieldName) {
|
|
1445
|
+
return getFieldError(error, fieldName) !== null;
|
|
1446
|
+
}
|
|
1447
|
+
/**
|
|
1448
|
+
* Get all field names that have validation errors
|
|
1449
|
+
*/
|
|
1450
|
+
function getErrorFieldNames(error) {
|
|
1451
|
+
const fieldErrors = getFieldErrors(error);
|
|
1452
|
+
return fieldErrors.map(err => err.field);
|
|
1453
|
+
}
|
|
1454
|
+
/**
|
|
1455
|
+
* Get validation response if available
|
|
1456
|
+
*/
|
|
1457
|
+
function getValidationResponse(error) {
|
|
1458
|
+
return error.validationResponse || null;
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Format field errors into a simple object for easy UI consumption
|
|
1462
|
+
*/
|
|
1463
|
+
function formatFieldErrors(error) {
|
|
1464
|
+
const fieldErrors = getFieldErrors(error);
|
|
1465
|
+
const formatted = {};
|
|
1466
|
+
fieldErrors.forEach(err => {
|
|
1467
|
+
formatted[err.field] = err.message;
|
|
1468
|
+
});
|
|
1469
|
+
return formatted;
|
|
1470
|
+
}
|
|
1471
|
+
/**
|
|
1472
|
+
* Format field errors with codes for advanced error handling
|
|
1473
|
+
*/
|
|
1474
|
+
function formatFieldErrorsWithCodes(error) {
|
|
1475
|
+
const fieldErrors = getFieldErrors(error);
|
|
1476
|
+
const formatted = {};
|
|
1477
|
+
fieldErrors.forEach(err => {
|
|
1478
|
+
formatted[err.field] = err;
|
|
1479
|
+
});
|
|
1480
|
+
return formatted;
|
|
1481
|
+
}
|
|
1482
|
+
/**
|
|
1483
|
+
* Get a human-readable summary of validation errors
|
|
1484
|
+
*/
|
|
1485
|
+
function getValidationErrorSummary(error) {
|
|
1486
|
+
if (!isFieldValidationError(error)) {
|
|
1487
|
+
return error.message || 'Validation error occurred';
|
|
1488
|
+
}
|
|
1489
|
+
const validationResponse = getValidationResponse(error);
|
|
1490
|
+
if (validationResponse?.message) {
|
|
1491
|
+
return validationResponse.message;
|
|
1492
|
+
}
|
|
1493
|
+
const fieldErrors = getFieldErrors(error);
|
|
1494
|
+
if (fieldErrors.length === 1) {
|
|
1495
|
+
return `${fieldErrors[0].field}: ${fieldErrors[0].message}`;
|
|
1496
|
+
}
|
|
1497
|
+
else if (fieldErrors.length > 1) {
|
|
1498
|
+
const fieldNames = fieldErrors.map(err => err.field).join(', ');
|
|
1499
|
+
return `Validation failed for fields: ${fieldNames}`;
|
|
1500
|
+
}
|
|
1501
|
+
return 'Form validation failed';
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* Create a map of field names to their error messages for easy form field highlighting
|
|
1505
|
+
*/
|
|
1506
|
+
function createFieldErrorMap(error) {
|
|
1507
|
+
const fieldErrors = getFieldErrors(error);
|
|
1508
|
+
const errorMap = new Map();
|
|
1509
|
+
fieldErrors.forEach(err => {
|
|
1510
|
+
errorMap.set(err.field, err.message);
|
|
1511
|
+
});
|
|
1512
|
+
return errorMap;
|
|
1513
|
+
}
|
|
1514
|
+
/**
|
|
1515
|
+
* Get the first field error (useful for focusing on the first invalid field)
|
|
1516
|
+
*/
|
|
1517
|
+
function getFirstFieldError(error) {
|
|
1518
|
+
const fieldErrors = getFieldErrors(error);
|
|
1519
|
+
return fieldErrors.length > 0 ? fieldErrors[0] : null;
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Check if validation errors include specific error codes
|
|
1523
|
+
*/
|
|
1524
|
+
function hasErrorCode(error, code) {
|
|
1525
|
+
const fieldErrors = getFieldErrors(error);
|
|
1526
|
+
return fieldErrors.some(err => err.code === code);
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* Check if a specific field has a specific error code
|
|
1530
|
+
*/
|
|
1531
|
+
function hasFieldErrorCode(error, fieldName, code) {
|
|
1532
|
+
const fieldError = getFieldError(error, fieldName);
|
|
1533
|
+
return fieldError?.code === code;
|
|
1534
|
+
}
|
|
1535
|
+
/**
|
|
1536
|
+
* Get all errors with a specific code
|
|
1537
|
+
*/
|
|
1538
|
+
function getErrorsByCode(error, code) {
|
|
1539
|
+
const fieldErrors = getFieldErrors(error);
|
|
1540
|
+
return fieldErrors.filter(err => err.code === code);
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Group errors by their error codes
|
|
1544
|
+
*/
|
|
1545
|
+
function groupErrorsByCode(error) {
|
|
1546
|
+
const fieldErrors = getFieldErrors(error);
|
|
1547
|
+
const grouped = {};
|
|
1548
|
+
fieldErrors.forEach(err => {
|
|
1549
|
+
if (!grouped[err.code]) {
|
|
1550
|
+
grouped[err.code] = [];
|
|
1551
|
+
}
|
|
1552
|
+
grouped[err.code].push(err);
|
|
1553
|
+
});
|
|
1554
|
+
return grouped;
|
|
1555
|
+
}
|
|
1556
|
+
/**
|
|
1557
|
+
* Create field error helpers for easy UI integration
|
|
1558
|
+
*/
|
|
1559
|
+
function createFieldErrorHelpers(error, defaultErrorClass = 'error') {
|
|
1560
|
+
return {
|
|
1561
|
+
getMessage: (fieldName) => {
|
|
1562
|
+
if (!error)
|
|
1563
|
+
return '';
|
|
1564
|
+
const fieldError = getFieldError(error, fieldName);
|
|
1565
|
+
return fieldError?.message || '';
|
|
1566
|
+
},
|
|
1567
|
+
hasError: (fieldName) => {
|
|
1568
|
+
if (!error)
|
|
1569
|
+
return false;
|
|
1570
|
+
return hasFieldError(error, fieldName);
|
|
1571
|
+
},
|
|
1572
|
+
getFieldClass: (fieldName, errorClass) => {
|
|
1573
|
+
if (!error)
|
|
1574
|
+
return '';
|
|
1575
|
+
const hasError = hasFieldError(error, fieldName);
|
|
1576
|
+
return hasError ? (errorClass || defaultErrorClass) : '';
|
|
1577
|
+
},
|
|
1578
|
+
getError: (fieldName) => {
|
|
1579
|
+
if (!error)
|
|
1580
|
+
return null;
|
|
1581
|
+
return getFieldError(error, fieldName);
|
|
1582
|
+
}
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Extract field validation errors from any error object
|
|
1587
|
+
* Useful when you're not sure if the error is a SubmitError
|
|
1588
|
+
*/
|
|
1589
|
+
function extractFieldErrors(error) {
|
|
1590
|
+
if (!error)
|
|
1591
|
+
return [];
|
|
1592
|
+
// If it's already a SubmitError
|
|
1593
|
+
if (error.fieldErrors && Array.isArray(error.fieldErrors)) {
|
|
1594
|
+
return error.fieldErrors;
|
|
1595
|
+
}
|
|
1596
|
+
// If it has a validationResponse
|
|
1597
|
+
if (error.validationResponse?.errors && Array.isArray(error.validationResponse.errors)) {
|
|
1598
|
+
return error.validationResponse.errors;
|
|
1599
|
+
}
|
|
1600
|
+
// Try to extract from response data
|
|
1601
|
+
if (error.response?.errors && Array.isArray(error.response.errors)) {
|
|
1602
|
+
return error.response.errors;
|
|
1603
|
+
}
|
|
1604
|
+
return [];
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/**
|
|
1608
|
+
* Formshive Submit Library
|
|
1609
|
+
*
|
|
1610
|
+
* A JavaScript library for submitting forms to Formshive with retry logic,
|
|
1611
|
+
* file support, and flexible HTTP client options.
|
|
1612
|
+
*/
|
|
1613
|
+
// Export main functionality
|
|
1614
|
+
// Set up global object for browser usage
|
|
1615
|
+
if (typeof window !== 'undefined') {
|
|
1616
|
+
window.FormshiveSubmit = {
|
|
1617
|
+
submitForm,
|
|
1618
|
+
submitFormSimple,
|
|
1619
|
+
FormSubmitter,
|
|
1620
|
+
validateFormData,
|
|
1621
|
+
RetryPresets,
|
|
1622
|
+
ERROR_CODES,
|
|
1623
|
+
formatFileSize,
|
|
1624
|
+
createLogger,
|
|
1625
|
+
// Field validation utilities
|
|
1626
|
+
isFieldValidationError,
|
|
1627
|
+
getFieldErrors,
|
|
1628
|
+
createFieldErrorHelpers,
|
|
1629
|
+
};
|
|
1630
|
+
}
|
|
1631
|
+
// Default export for convenience
|
|
1632
|
+
var main = {
|
|
1633
|
+
submitForm,
|
|
1634
|
+
submitFormSimple,
|
|
1635
|
+
FormSubmitter,
|
|
1636
|
+
validateFormData,
|
|
1637
|
+
RetryPresets,
|
|
1638
|
+
ERROR_CODES,
|
|
1639
|
+
formatFileSize,
|
|
1640
|
+
createLogger,
|
|
1641
|
+
// Field validation utilities
|
|
1642
|
+
isFieldValidationError,
|
|
1643
|
+
getFieldErrors,
|
|
1644
|
+
createFieldErrorHelpers,
|
|
1645
|
+
};
|
|
1646
|
+
|
|
1647
|
+
exports.ConsoleLogger = ConsoleLogger;
|
|
1648
|
+
exports.DEFAULT_FILE_CONFIG = DEFAULT_FILE_CONFIG;
|
|
1649
|
+
exports.DEFAULT_OPTIONS = DEFAULT_OPTIONS;
|
|
1650
|
+
exports.DEFAULT_RETRY_CONFIG = DEFAULT_RETRY_CONFIG;
|
|
1651
|
+
exports.ERROR_CODES = ERROR_CODES;
|
|
1652
|
+
exports.FormSubmitter = FormSubmitter;
|
|
1653
|
+
exports.NoOpLogger = NoOpLogger;
|
|
1654
|
+
exports.RetryManager = RetryManager;
|
|
1655
|
+
exports.RetryPresets = RetryPresets;
|
|
1656
|
+
exports.buildSubmissionUrl = buildSubmissionUrl;
|
|
1657
|
+
exports.createFieldErrorHelpers = createFieldErrorHelpers;
|
|
1658
|
+
exports.createFieldErrorMap = createFieldErrorMap;
|
|
1659
|
+
exports.createHttpClient = createHttpClient;
|
|
1660
|
+
exports.createLogger = createLogger;
|
|
1661
|
+
exports.createProgressTracker = createProgressTracker;
|
|
1662
|
+
exports.createRetryableFunction = createRetryableFunction;
|
|
1663
|
+
exports.createSubmitError = createSubmitError;
|
|
1664
|
+
exports.createTimeoutPromise = createTimeoutPromise;
|
|
1665
|
+
exports.debounce = debounce;
|
|
1666
|
+
exports.deepClone = deepClone;
|
|
1667
|
+
exports.default = main;
|
|
1668
|
+
exports.ensureProtocol = ensureProtocol;
|
|
1669
|
+
exports.extractFieldErrors = extractFieldErrors;
|
|
1670
|
+
exports.extractFiles = extractFiles;
|
|
1671
|
+
exports.formatDuration = formatDuration;
|
|
1672
|
+
exports.formatFieldErrors = formatFieldErrors;
|
|
1673
|
+
exports.formatFieldErrorsWithCodes = formatFieldErrorsWithCodes;
|
|
1674
|
+
exports.formatFileSize = formatFileSize;
|
|
1675
|
+
exports.generateRequestId = generateRequestId;
|
|
1676
|
+
exports.getEnhancedFileInfo = getEnhancedFileInfo;
|
|
1677
|
+
exports.getEnvironmentInfo = getEnvironmentInfo;
|
|
1678
|
+
exports.getErrorFieldNames = getErrorFieldNames;
|
|
1679
|
+
exports.getErrorMessage = getErrorMessage;
|
|
1680
|
+
exports.getErrorsByCode = getErrorsByCode;
|
|
1681
|
+
exports.getFieldError = getFieldError;
|
|
1682
|
+
exports.getFieldErrors = getFieldErrors;
|
|
1683
|
+
exports.getFirstFieldError = getFirstFieldError;
|
|
1684
|
+
exports.getMimeTypeFromExtension = getMimeTypeFromExtension;
|
|
1685
|
+
exports.getNestedProperty = getNestedProperty;
|
|
1686
|
+
exports.getRetryDelayInfo = getRetryDelayInfo;
|
|
1687
|
+
exports.getValidationErrorSummary = getValidationErrorSummary;
|
|
1688
|
+
exports.getValidationResponse = getValidationResponse;
|
|
1689
|
+
exports.groupErrorsByCode = groupErrorsByCode;
|
|
1690
|
+
exports.hasErrorCode = hasErrorCode;
|
|
1691
|
+
exports.hasFieldError = hasFieldError;
|
|
1692
|
+
exports.hasFieldErrorCode = hasFieldErrorCode;
|
|
1693
|
+
exports.isBrowser = isBrowser;
|
|
1694
|
+
exports.isFetchAvailable = isFetchAvailable;
|
|
1695
|
+
exports.isFieldValidationError = isFieldValidationError;
|
|
1696
|
+
exports.isFormDataAvailable = isFormDataAvailable;
|
|
1697
|
+
exports.isRetryableError = isRetryableError;
|
|
1698
|
+
exports.isValidUrl = isValidUrl;
|
|
1699
|
+
exports.mergeObjects = mergeObjects;
|
|
1700
|
+
exports.objectToQueryString = objectToQueryString;
|
|
1701
|
+
exports.parseErrorMessage = parseErrorMessage;
|
|
1702
|
+
exports.processFormData = processFormData;
|
|
1703
|
+
exports.sanitizeForLogging = sanitizeForLogging;
|
|
1704
|
+
exports.simpleRetry = simpleRetry;
|
|
1705
|
+
exports.submitForm = submitForm;
|
|
1706
|
+
exports.submitFormSimple = submitFormSimple;
|
|
1707
|
+
exports.submitFormWithCallbacks = submitFormWithCallbacks;
|
|
1708
|
+
exports.throttle = throttle;
|
|
1709
|
+
exports.validateFile = validateFile;
|
|
1710
|
+
exports.validateFiles = validateFiles;
|
|
1711
|
+
exports.validateFormData = validateFormData;
|
|
1712
|
+
exports.withRetry = withRetry;
|
|
1713
|
+
exports.withTimeout = withTimeout;
|
|
1714
|
+
//# sourceMappingURL=formshive-submit.cjs.map
|