@doyosi/laraisy 1.0.2 → 1.0.3
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/LICENSE +1 -1
- package/package.json +1 -1
- package/src/CodeInput.js +48 -48
- package/src/DSAlert.js +352 -352
- package/src/DSAvatar.js +207 -207
- package/src/DSDelete.js +274 -274
- package/src/DSForm.js +568 -568
- package/src/DSGridOrTable.js +453 -453
- package/src/DSLocaleSwitcher.js +239 -239
- package/src/DSLogout.js +293 -293
- package/src/DSNotifications.js +365 -365
- package/src/DSRestore.js +181 -181
- package/src/DSSelect.js +1071 -1071
- package/src/DSSelectBox.js +563 -563
- package/src/DSSimpleSlider.js +517 -517
- package/src/DSSvgFetch.js +69 -69
- package/src/DSTable/DSTableExport.js +68 -68
- package/src/DSTable/DSTableFilter.js +224 -224
- package/src/DSTable/DSTablePagination.js +136 -136
- package/src/DSTable/DSTableSearch.js +40 -40
- package/src/DSTable/DSTableSelection.js +192 -192
- package/src/DSTable/DSTableSort.js +58 -58
- package/src/DSTable.js +353 -353
- package/src/DSTabs.js +488 -488
- package/src/DSUpload.js +887 -887
- package/dist/CodeInput.d.ts +0 -10
- package/dist/DSAlert.d.ts +0 -112
- package/dist/DSAvatar.d.ts +0 -45
- package/dist/DSDelete.d.ts +0 -61
- package/dist/DSForm.d.ts +0 -151
- package/dist/DSGridOrTable/DSGOTRenderer.d.ts +0 -60
- package/dist/DSGridOrTable/DSGOTViewToggle.d.ts +0 -26
- package/dist/DSGridOrTable.d.ts +0 -296
- package/dist/DSLocaleSwitcher.d.ts +0 -71
- package/dist/DSLogout.d.ts +0 -76
- package/dist/DSNotifications.d.ts +0 -54
- package/dist/DSRestore.d.ts +0 -56
- package/dist/DSSelect.d.ts +0 -221
- package/dist/DSSelectBox.d.ts +0 -123
- package/dist/DSSimpleSlider.d.ts +0 -136
- package/dist/DSSvgFetch.d.ts +0 -17
- package/dist/DSTable/DSTableExport.d.ts +0 -11
- package/dist/DSTable/DSTableFilter.d.ts +0 -40
- package/dist/DSTable/DSTablePagination.d.ts +0 -12
- package/dist/DSTable/DSTableSearch.d.ts +0 -8
- package/dist/DSTable/DSTableSelection.d.ts +0 -46
- package/dist/DSTable/DSTableSort.d.ts +0 -8
- package/dist/DSTable.d.ts +0 -116
- package/dist/DSTabs.d.ts +0 -156
- package/dist/DSUpload.d.ts +0 -220
- package/dist/index.d.ts +0 -17
package/src/DSForm.js
CHANGED
|
@@ -1,569 +1,569 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* DSForm (with DSAlert integration)
|
|
3
|
-
*
|
|
4
|
-
* A modern form handler that includes:
|
|
5
|
-
* - Integration with DSAlert for Toasts and Modals.
|
|
6
|
-
* - AJAX submission via 'fetch' | 'axios' | 'xhr'.
|
|
7
|
-
* - Laravel-style error mapping (dot/bracket notation).
|
|
8
|
-
* - Lifecycle hooks (onBeforeSubmit, onSubmit, onSuccess, onError, onComplete).
|
|
9
|
-
* - Automatic disable/loading states.
|
|
10
|
-
* - Data injection before submit.
|
|
11
|
-
*/
|
|
12
|
-
import { DSAlert } from './DSAlert.js';
|
|
13
|
-
|
|
14
|
-
export class DSForm {
|
|
15
|
-
/**
|
|
16
|
-
* @param {Object} config - Configuration object.
|
|
17
|
-
* @param {HTMLFormElement|string} config.form - Form element or selector.
|
|
18
|
-
* @param {string} [config.url] - Submit URL (defaults to form.action or current url).
|
|
19
|
-
* @param {'post'|'put'} [config.method] - HTTP method.
|
|
20
|
-
* @param {Array<string|HTMLElement>} [config.triggers] - External triggers (selectors/elements).
|
|
21
|
-
* @param {'fetch'|'axios'|'xhr'} [config.requestLib] - Preferred request lib; gracefully falls back.
|
|
22
|
-
* @param {Object} [config.headers] - Extra headers.
|
|
23
|
-
* @param {Object|Function} [config.additionalData] - Extra data merged before submit.
|
|
24
|
-
* @param {Array<string>} [config.disableSelectors] - Extra selectors to disable during submit.
|
|
25
|
-
* @param {Array<string>} [config.excludeEnableSelectors] - Keep these disabled after complete.
|
|
26
|
-
* @param {string} [config.primaryButtonSelector='button[type="submit"]'] - For default loading target.
|
|
27
|
-
* @param {(Function|string)} [config.loadingTemplate] - Function/Selector/Raw HTML for loading state.
|
|
28
|
-
* @param {Object} [config.translations] - i18n map for user texts.
|
|
29
|
-
* @param {Object} [config.toast] - Toast options { enabled, position, timer, timerProgressBar }.
|
|
30
|
-
* @param {boolean} [config.autoRedirect=true] - Follow payload.redirect if present.
|
|
31
|
-
* @param {boolean} [config.disableOnSuccess=false] - Keep all controls disabled if the request succeeds.
|
|
32
|
-
* @param {Function} [config.onBeforeSubmit]
|
|
33
|
-
* @param {Function} [config.onSubmit]
|
|
34
|
-
* @param {Function} [config.onSuccess]
|
|
35
|
-
* @param {Function} [config.onError]
|
|
36
|
-
* @param {Function} [config.onComplete]
|
|
37
|
-
* @param {string} [config.successTitle] - Title for success toast (overrides payload.message).
|
|
38
|
-
* @param {string} [config.errorTitle] - Title for error toast (overrides payload.message).
|
|
39
|
-
*/
|
|
40
|
-
constructor(config = {}) {
|
|
41
|
-
const defaults = {
|
|
42
|
-
requestLib: 'fetch',
|
|
43
|
-
primaryButtonSelector: 'button[type="submit"]',
|
|
44
|
-
translations: {
|
|
45
|
-
loading: 'Loading...',
|
|
46
|
-
networkError: 'Network error.',
|
|
47
|
-
unknownError: 'Something went wrong.',
|
|
48
|
-
success: 'Success!',
|
|
49
|
-
error: 'There was a problem.'
|
|
50
|
-
},
|
|
51
|
-
// Removed useSwal, added standard defaults compatible with DSAlert
|
|
52
|
-
toast: { enabled: false, position: 'top-end', timer: 3000, timerProgressBar: true },
|
|
53
|
-
autoRedirect: true,
|
|
54
|
-
disableOnSuccess: false,
|
|
55
|
-
successTitle: null,
|
|
56
|
-
errorTitle: null,
|
|
57
|
-
};
|
|
58
|
-
|
|
59
|
-
this.cfg = { ...defaults, ...config };
|
|
60
|
-
|
|
61
|
-
this.form = typeof this.cfg.form === 'string' ? document.querySelector(this.cfg.form) : this.cfg.form;
|
|
62
|
-
if (!this.form) throw new Error('DSForm: form not found.');
|
|
63
|
-
|
|
64
|
-
this.url = this.cfg.url || this.form.getAttribute('action') || window.location.href;
|
|
65
|
-
this.method = (this.cfg.method || this.form.getAttribute('method') || 'post').toLowerCase();
|
|
66
|
-
|
|
67
|
-
this.triggers = (this.cfg.triggers || [])
|
|
68
|
-
.map(t => (typeof t === 'string' ? document.querySelector(t) : t))
|
|
69
|
-
.filter(Boolean);
|
|
70
|
-
|
|
71
|
-
this._activeTrigger = null;
|
|
72
|
-
this._disabledEls = new Set();
|
|
73
|
-
|
|
74
|
-
// Mini event emitter
|
|
75
|
-
this._listeners = {};
|
|
76
|
-
|
|
77
|
-
// Bind handlers once (per instance)
|
|
78
|
-
this._onFormSubmit = this._onFormSubmit.bind(this);
|
|
79
|
-
this._onExternalTrigger = this._onExternalTrigger.bind(this);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Attach listeners to form and external triggers */
|
|
83
|
-
bind() {
|
|
84
|
-
this.form.addEventListener('submit', this._onFormSubmit);
|
|
85
|
-
for (const el of this.triggers) el.addEventListener('click', this._onExternalTrigger);
|
|
86
|
-
return this;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** Detach listeners */
|
|
90
|
-
unbind() {
|
|
91
|
-
this.form.removeEventListener('submit', this._onFormSubmit);
|
|
92
|
-
for (const el of this.triggers) el.removeEventListener('click', this._onExternalTrigger);
|
|
93
|
-
return this;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** Emitter: subscribe to internal events (and mirrored DOM CustomEvents) */
|
|
97
|
-
on(event, handler) {
|
|
98
|
-
if (!this._listeners[event]) this._listeners[event] = new Set();
|
|
99
|
-
this._listeners[event].add(handler);
|
|
100
|
-
return this;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Emitter: unsubscribe */
|
|
104
|
-
off(event, handler) {
|
|
105
|
-
if (this._listeners[event]) this._listeners[event].delete(handler);
|
|
106
|
-
return this;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Programmatically submit (optional). Runs native validation via requestSubmit if supported. */
|
|
110
|
-
submit({ trigger = null } = {}) {
|
|
111
|
-
this._activeTrigger = trigger instanceof HTMLElement ? trigger : null;
|
|
112
|
-
if (typeof this.form.requestSubmit === 'function') {
|
|
113
|
-
const btn = this._activeTrigger?.type === 'submit' ? this._activeTrigger : this.form.querySelector(this.cfg.primaryButtonSelector);
|
|
114
|
-
btn ? this.form.requestSubmit(btn) : this.form.requestSubmit();
|
|
115
|
-
} else {
|
|
116
|
-
this.form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** Public helper to merge extra data into request payload creation pipeline */
|
|
121
|
-
addData(extra = {}) {
|
|
122
|
-
if (typeof this.cfg.additionalData === 'function') {
|
|
123
|
-
const prev = this.cfg.additionalData;
|
|
124
|
-
this.cfg.additionalData = (data) => ({ ...prev(data), ...extra });
|
|
125
|
-
} else if (this.cfg.additionalData && typeof this.cfg.additionalData === 'object') {
|
|
126
|
-
this.cfg.additionalData = { ...this.cfg.additionalData, ...extra };
|
|
127
|
-
} else {
|
|
128
|
-
this.cfg.additionalData = { ...extra };
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// ================= Internal =================
|
|
133
|
-
|
|
134
|
-
_onExternalTrigger(e) {
|
|
135
|
-
e.preventDefault();
|
|
136
|
-
const trigger = e.currentTarget;
|
|
137
|
-
this.submit({ trigger });
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async _onFormSubmit(e) {
|
|
141
|
-
e.preventDefault();
|
|
142
|
-
|
|
143
|
-
// Reset field errors each submit
|
|
144
|
-
this._resetErrors();
|
|
145
|
-
|
|
146
|
-
const { body, isFormData, jsonSnapshot } = this._buildBody();
|
|
147
|
-
const headers = this._buildHeaders(isFormData);
|
|
148
|
-
const controller = new AbortController();
|
|
149
|
-
|
|
150
|
-
// Hook: before
|
|
151
|
-
try { this.cfg.onBeforeSubmit?.({ formData: isFormData ? body : null, json: isFormData ? null : jsonSnapshot }); } catch (err) { console.warn('onBeforeSubmit error:', err); }
|
|
152
|
-
|
|
153
|
-
// Disable UI + loading
|
|
154
|
-
const loadingText = this._t('loading');
|
|
155
|
-
this._disableAll(loadingText);
|
|
156
|
-
|
|
157
|
-
this._emit('submit:start', { controller });
|
|
158
|
-
try { this.cfg.onSubmit?.({ controller }); } catch (err) { console.warn('onSubmit error:', err); }
|
|
159
|
-
|
|
160
|
-
let ok = false; let response, data;
|
|
161
|
-
|
|
162
|
-
try {
|
|
163
|
-
({ response, data } = await this._sendRequest({ body, headers, signal: controller.signal, isFormData }));
|
|
164
|
-
ok = response.ok;
|
|
165
|
-
|
|
166
|
-
if (ok) {
|
|
167
|
-
this._emit('success', { response, data });
|
|
168
|
-
try { this.cfg.onSuccess?.({ response, data }); } catch (err) { console.warn('onSuccess error:', err); }
|
|
169
|
-
this._toast(this.cfg.successTitle || data?.message || this._t('success'), 'success');
|
|
170
|
-
if (this.cfg.autoRedirect) {
|
|
171
|
-
const payload = data?.data ?? data; // Laravel style
|
|
172
|
-
const url = payload?.redirect || payload?.url;
|
|
173
|
-
if (url) window.location.assign(url);
|
|
174
|
-
}
|
|
175
|
-
} else {
|
|
176
|
-
this._renderErrors(data);
|
|
177
|
-
this._emit('error', { response, data });
|
|
178
|
-
try { this.cfg.onError?.({ response, data }); } catch (err) { console.warn('onError error:', err); }
|
|
179
|
-
this._toast(this.cfg.errorTitle || data?.message || this._t('unknownError'), 'error');
|
|
180
|
-
}
|
|
181
|
-
} catch (err) {
|
|
182
|
-
console.error(err);
|
|
183
|
-
this._renderGeneralError(this._t('networkError'));
|
|
184
|
-
this._emit('error', { response: null, data: null, error: err });
|
|
185
|
-
try { this.cfg.onError?.({ response: null, data: null, error: err }); } catch (e2) { console.warn('onError error:', e2); }
|
|
186
|
-
this._toast(this._t('networkError'), 'error');
|
|
187
|
-
} finally {
|
|
188
|
-
// Only re-enable if not configured to keep disabled on success
|
|
189
|
-
if (!(this.cfg.disableOnSuccess && ok)) {
|
|
190
|
-
this._enableAll();
|
|
191
|
-
}
|
|
192
|
-
this._emit('submit:complete', { ok });
|
|
193
|
-
try { this.cfg.onComplete?.({ ok }); } catch (err) { console.warn('onComplete error:', err); }
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
_buildBody() {
|
|
198
|
-
const formData = new FormData(this.form);
|
|
199
|
-
|
|
200
|
-
// Merge additional data
|
|
201
|
-
let extra = this.cfg.additionalData || null;
|
|
202
|
-
if (typeof extra === 'function') extra = extra(Object.fromEntries(formData.entries())) || null;
|
|
203
|
-
if (extra && typeof extra === 'object') {
|
|
204
|
-
for (const [k, v] of Object.entries(extra)) this._appendToFormData(formData, k, v);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const hasFile = Array.from(this.form.querySelectorAll('input[type="file"]')).some(i => i.files && i.files.length > 0);
|
|
208
|
-
const enctype = (this.form.getAttribute('enctype') || '').toLowerCase();
|
|
209
|
-
|
|
210
|
-
if (hasFile || enctype.includes('multipart')) {
|
|
211
|
-
return { body: formData, isFormData: true, jsonSnapshot: null };
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const json = this._formDataToJSON(formData);
|
|
215
|
-
return { body: JSON.stringify(json), isFormData: false, jsonSnapshot: json };
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
_appendToFormData(fd, key, val) {
|
|
219
|
-
if (val === undefined) return;
|
|
220
|
-
if (val === null) { fd.append(key, ''); return; }
|
|
221
|
-
if (Array.isArray(val)) { for (const item of val) fd.append(this._ensureArrayKey(key), item); return; }
|
|
222
|
-
if (typeof val === 'object') { for (const [k, v] of Object.entries(val)) this._appendToFormData(fd, `${key}[${k}]`, v); return; }
|
|
223
|
-
fd.append(key, String(val));
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
_ensureArrayKey(key) { return key.endsWith('[]') ? key : `${key}[]`; }
|
|
227
|
-
|
|
228
|
-
_formDataToJSON(fd) {
|
|
229
|
-
const obj = {};
|
|
230
|
-
for (const [k, v] of fd.entries()) this._assignDeep(obj, k, v);
|
|
231
|
-
return obj;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
_assignDeep(obj, path, value) {
|
|
235
|
-
const parts = this._parsePath(path);
|
|
236
|
-
let cur = obj;
|
|
237
|
-
for (let i = 0; i < parts.length; i++) {
|
|
238
|
-
const p = parts[i];
|
|
239
|
-
const last = i === parts.length - 1;
|
|
240
|
-
if (last) {
|
|
241
|
-
if (p === '[]') {
|
|
242
|
-
if (!Array.isArray(cur)) throw new Error(`Path misuse at ${path}`);
|
|
243
|
-
cur.push(value);
|
|
244
|
-
} else if (p.endsWith('[]')) {
|
|
245
|
-
const key = p.slice(0, -2);
|
|
246
|
-
if (!Array.isArray(cur[key])) cur[key] = [];
|
|
247
|
-
cur[key].push(value);
|
|
248
|
-
} else {
|
|
249
|
-
cur[p] = value;
|
|
250
|
-
}
|
|
251
|
-
} else {
|
|
252
|
-
if (p.endsWith('[]')) {
|
|
253
|
-
const key = p.slice(0, -2);
|
|
254
|
-
if (!Array.isArray(cur[key])) cur[key] = [];
|
|
255
|
-
if (cur[key].length === 0 || typeof cur[key][cur[key].length - 1] !== 'object') cur[key].push({});
|
|
256
|
-
cur = cur[key][cur.length - 1];
|
|
257
|
-
} else {
|
|
258
|
-
if (!cur[p] || typeof cur[p] !== 'object') cur[p] = {};
|
|
259
|
-
cur = cur[p];
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
_parsePath(path) {
|
|
266
|
-
const parts = [];
|
|
267
|
-
|
|
268
|
-
// Check if path ends with [] (array notation like tags[], categories[])
|
|
269
|
-
const isArrayField = path.endsWith('[]');
|
|
270
|
-
|
|
271
|
-
// Remove trailing [] temporarily for parsing
|
|
272
|
-
let cleanPath = isArrayField ? path.slice(0, -2) : path;
|
|
273
|
-
|
|
274
|
-
// Parse the path: field[nested][key] -> [field, nested, key]
|
|
275
|
-
cleanPath.replace(/\]/g, '').split('[').forEach(p => {
|
|
276
|
-
if (p) parts.push(p);
|
|
277
|
-
});
|
|
278
|
-
|
|
279
|
-
// If it was an array field, add [] to the last part
|
|
280
|
-
if (isArrayField && parts.length > 0) {
|
|
281
|
-
parts[parts.length - 1] = parts[parts.length - 1] + '[]';
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
return parts;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
_buildHeaders(isFormData) {
|
|
288
|
-
const headers = { ...(this.cfg.headers || {}) };
|
|
289
|
-
|
|
290
|
-
// CSRF from meta or hidden input
|
|
291
|
-
const csrf =
|
|
292
|
-
document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ||
|
|
293
|
-
this.form.querySelector('input[name="_token"]')?.value ||
|
|
294
|
-
null;
|
|
295
|
-
|
|
296
|
-
if (csrf) headers['X-CSRF-TOKEN'] = csrf;
|
|
297
|
-
if (!isFormData) headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
|
298
|
-
headers['Accept'] = headers['Accept'] || 'application/json';
|
|
299
|
-
return headers;
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
async _sendRequest({ body, headers, signal, isFormData }) {
|
|
303
|
-
const url = this.url;
|
|
304
|
-
const method = this.method;
|
|
305
|
-
const lib = (this.cfg.requestLib || 'fetch').toLowerCase();
|
|
306
|
-
|
|
307
|
-
// axios path
|
|
308
|
-
if (lib === 'axios' && window.axios) {
|
|
309
|
-
const resp = await window.axios({
|
|
310
|
-
url,
|
|
311
|
-
method,
|
|
312
|
-
data: isFormData ? body : JSON.parse(body || '{}'),
|
|
313
|
-
headers,
|
|
314
|
-
signal,
|
|
315
|
-
}).catch(err => (err.response ? err.response : Promise.reject(err)));
|
|
316
|
-
|
|
317
|
-
const ok = (resp.status >= 200 && resp.status < 300);
|
|
318
|
-
const data = resp.data && typeof resp.data === 'object' ? resp.data : {};
|
|
319
|
-
return { response: { ok, status: resp.status }, data };
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// fetch path (default & axios fallback)
|
|
323
|
-
if (window.fetch) {
|
|
324
|
-
const resp = await fetch(url, { method, headers, body, signal, credentials: 'same-origin' });
|
|
325
|
-
let data = null; try { data = await resp.json(); } catch { data = {}; }
|
|
326
|
-
return { response: resp, data };
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// xhr fallback
|
|
330
|
-
const { response, data } = await this._xhr({ url, method, headers, body, isFormData, signal });
|
|
331
|
-
return { response, data };
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
_xhr({ url, method, headers, body, isFormData, signal }) {
|
|
335
|
-
return new Promise((resolve, reject) => {
|
|
336
|
-
const xhr = new XMLHttpRequest();
|
|
337
|
-
xhr.open(method.toUpperCase(), url, true);
|
|
338
|
-
xhr.responseType = 'json';
|
|
339
|
-
for (const [k, v] of Object.entries(headers || {})) xhr.setRequestHeader(k, v);
|
|
340
|
-
xhr.onload = () => {
|
|
341
|
-
const ok = xhr.status >= 200 && xhr.status < 300;
|
|
342
|
-
resolve({ response: { ok, status: xhr.status }, data: xhr.response || {} });
|
|
343
|
-
};
|
|
344
|
-
xhr.onerror = () => reject(new Error('XHR network error'));
|
|
345
|
-
if (signal) signal.addEventListener('abort', () => { try { xhr.abort(); } catch { } reject(new Error('Aborted')); });
|
|
346
|
-
isFormData ? xhr.send(body) : xhr.send(body || null);
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
_disableAll(loadingText) {
|
|
351
|
-
this._disabledEls.clear();
|
|
352
|
-
|
|
353
|
-
// Disable all form elements
|
|
354
|
-
const elements = Array.from(this.form.querySelectorAll('input, select, textarea, button, a, [tabindex]'));
|
|
355
|
-
const extra = (this.cfg.disableSelectors || []).flatMap(sel => Array.from(document.querySelectorAll(sel)));
|
|
356
|
-
|
|
357
|
-
// Include the active trigger if present
|
|
358
|
-
const setToDisable = new Set([...elements, ...extra, ...(this._activeTrigger ? [this._activeTrigger] : [])]);
|
|
359
|
-
|
|
360
|
-
for (const el of setToDisable) {
|
|
361
|
-
if (!(el instanceof HTMLElement)) continue;
|
|
362
|
-
|
|
363
|
-
// Store previous state for restore
|
|
364
|
-
if (!el.dataset._dsfDisabled) el.dataset._dsfDisabled = el.hasAttribute('disabled') ? '1' : '0';
|
|
365
|
-
if (!el.dataset._dsfHTML) el.dataset._dsfHTML = el.innerHTML || '';
|
|
366
|
-
|
|
367
|
-
// Disable
|
|
368
|
-
if (el.tagName === 'A') {
|
|
369
|
-
el.dataset._dsfHref = el.getAttribute('href') || '';
|
|
370
|
-
el.setAttribute('href', 'javascript:void(0)');
|
|
371
|
-
el.classList.add('pointer-events-none');
|
|
372
|
-
}
|
|
373
|
-
el.setAttribute('disabled', 'disabled');
|
|
374
|
-
el.classList.add('disabled');
|
|
375
|
-
|
|
376
|
-
this._disabledEls.add(el);
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// Loading UI on the most relevant button/trigger
|
|
380
|
-
const targetBtn =
|
|
381
|
-
this._activeTrigger && this._isButtonLike(this._activeTrigger)
|
|
382
|
-
? this._activeTrigger
|
|
383
|
-
: this.form.querySelector(this.cfg.primaryButtonSelector);
|
|
384
|
-
|
|
385
|
-
if (targetBtn && targetBtn instanceof HTMLElement) {
|
|
386
|
-
targetBtn.innerHTML = this._resolveTemplate(this.cfg.loadingTemplate, loadingText) ||
|
|
387
|
-
`<span class="loading loading-bars loading-md"></span><span>${loadingText}</span>`;
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
_enableAll() {
|
|
392
|
-
const exclude = new Set((this.cfg.excludeEnableSelectors || [])
|
|
393
|
-
.flatMap(sel => Array.from(document.querySelectorAll(sel))));
|
|
394
|
-
|
|
395
|
-
for (const el of this._disabledEls) {
|
|
396
|
-
if (!(el instanceof HTMLElement)) continue;
|
|
397
|
-
if (exclude.has(el)) continue;
|
|
398
|
-
|
|
399
|
-
// Restore href for <a>
|
|
400
|
-
if (el.tagName === 'A') {
|
|
401
|
-
if (el.dataset._dsfHref !== undefined) el.setAttribute('href', el.dataset._dsfHref);
|
|
402
|
-
el.classList.remove('pointer-events-none');
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
// Restore disabled state only if we disabled it
|
|
406
|
-
if (el.dataset._dsfDisabled === '0') {
|
|
407
|
-
el.removeAttribute('disabled');
|
|
408
|
-
el.classList.remove('disabled');
|
|
409
|
-
} else {
|
|
410
|
-
// It was already disabled before; keep it disabled
|
|
411
|
-
el.classList.add('disabled');
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// Restore innerHTML if we changed it
|
|
415
|
-
if (el.dataset._dsfHTML !== undefined && this._isButtonLike(el)) {
|
|
416
|
-
el.innerHTML = el.dataset._dsfHTML;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
// Cleanup datasets
|
|
420
|
-
delete el.dataset._dsfDisabled;
|
|
421
|
-
delete el.dataset._dsfHTML;
|
|
422
|
-
delete el.dataset._dsfHref;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
this._disabledEls.clear();
|
|
426
|
-
this._activeTrigger = null;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
_isButtonLike(el) {
|
|
430
|
-
if (!(el instanceof HTMLElement)) return false;
|
|
431
|
-
const tag = el.tagName.toLowerCase();
|
|
432
|
-
if (tag === 'button') return true;
|
|
433
|
-
if (tag === 'a') return true;
|
|
434
|
-
if (el.getAttribute('role') === 'button') return true;
|
|
435
|
-
return false;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
_resolveTemplate(tpl, text) {
|
|
439
|
-
if (!tpl) return '';
|
|
440
|
-
if (typeof tpl === 'function') return tpl(text);
|
|
441
|
-
if (typeof tpl === 'string') {
|
|
442
|
-
if (tpl.trim().startsWith('#')) {
|
|
443
|
-
const node = document.querySelector(tpl);
|
|
444
|
-
return node ? node.innerHTML : '';
|
|
445
|
-
}
|
|
446
|
-
return tpl; // raw HTML
|
|
447
|
-
}
|
|
448
|
-
return '';
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
_resetErrors() {
|
|
452
|
-
const spans = this.form.querySelectorAll('.form-error[data-input]');
|
|
453
|
-
for (const s of spans) {
|
|
454
|
-
s.textContent = '';
|
|
455
|
-
s.classList.add('hidden');
|
|
456
|
-
}
|
|
457
|
-
const summary = this.form.querySelector('.form-error-summary');
|
|
458
|
-
if (summary) {
|
|
459
|
-
summary.textContent = '';
|
|
460
|
-
summary.classList.add('hidden');
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
_renderErrors(payload) {
|
|
465
|
-
if (!payload || typeof payload !== 'object') return;
|
|
466
|
-
const errors = payload.errors || {};
|
|
467
|
-
const message = payload.message || this.cfg.errorTitle || this._t('unknownError');
|
|
468
|
-
|
|
469
|
-
// Show per-field
|
|
470
|
-
for (const [key, arr] of Object.entries(errors)) {
|
|
471
|
-
const text = Array.isArray(arr) ? arr[0] : String(arr);
|
|
472
|
-
this._showErrorForKey(key, text);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
// If no per-field target(s) found, show a summary (optional)
|
|
476
|
-
if (Object.keys(errors).length === 0) {
|
|
477
|
-
this._renderGeneralError(message);
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
_showErrorForKey(key, text) {
|
|
482
|
-
// Accept both dot and bracket notations
|
|
483
|
-
const variants = new Set([
|
|
484
|
-
key,
|
|
485
|
-
key.replace(/\./g, '[') + ']',
|
|
486
|
-
key.replace(/\[(\w+)\]/g, '.$1').replace(/^\./, ''),
|
|
487
|
-
]);
|
|
488
|
-
let shown = false;
|
|
489
|
-
for (const v of variants) {
|
|
490
|
-
const span = this.form.querySelector(`.form-error[data-input="${CSS.escape(v)}"]`);
|
|
491
|
-
if (span) {
|
|
492
|
-
span.textContent = text;
|
|
493
|
-
span.classList.remove('hidden');
|
|
494
|
-
shown = true;
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
if (!shown) {
|
|
498
|
-
const control = this.form.querySelector(`[name="${CSS.escape(key)}"]`) ||
|
|
499
|
-
this.form.querySelector(`[name="${CSS.escape(key.replace(/\./g, '[') + ']')}"]`);
|
|
500
|
-
if (control) {
|
|
501
|
-
let span = control.closest('label, .form-control, .form-group, div')?.querySelector('.form-error');
|
|
502
|
-
if (!span) {
|
|
503
|
-
span = document.createElement('span');
|
|
504
|
-
span.className = 'form-error text-error text-sm';
|
|
505
|
-
control.insertAdjacentElement('afterend', span);
|
|
506
|
-
}
|
|
507
|
-
span.textContent = text;
|
|
508
|
-
span.classList.remove('hidden');
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
_renderGeneralError(msg) {
|
|
514
|
-
const summary = this.form.querySelector('.form-error-summary');
|
|
515
|
-
if (summary) {
|
|
516
|
-
summary.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">\n' +
|
|
517
|
-
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />\n' +
|
|
518
|
-
'</svg>' + msg;
|
|
519
|
-
summary.classList.remove('hidden');
|
|
520
|
-
} else {
|
|
521
|
-
// Updated to use DSAlert instead of native alert
|
|
522
|
-
DSAlert.fire({
|
|
523
|
-
title: this._t('error'),
|
|
524
|
-
text: msg,
|
|
525
|
-
icon: 'error',
|
|
526
|
-
confirmButtonText: 'OK'
|
|
527
|
-
});
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
_emit(event, detail = {}) {
|
|
532
|
-
// Call registered handlers
|
|
533
|
-
(this._listeners[event] || new Set()).forEach(fn => { try { fn(detail); } catch (err) { console.warn(err); } });
|
|
534
|
-
// Mirror as DOM CustomEvent on the form element
|
|
535
|
-
this.form.dispatchEvent(new CustomEvent(`smartform:${event}`, { bubbles: true, detail }));
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
_t(key) {
|
|
539
|
-
return (this.cfg.translations && this.cfg.translations[key]) || DSForm._defaults.translations[key] || key;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
/** Toast helper using DSAlert */
|
|
543
|
-
_toast(message, type = 'info') {
|
|
544
|
-
if (!this.cfg.toast?.enabled) return;
|
|
545
|
-
|
|
546
|
-
const msg = message || (type === 'success' ? this._t('success') : this._t('error'));
|
|
547
|
-
|
|
548
|
-
// Map DSForm toast config to DSAlert config
|
|
549
|
-
DSAlert.fire({
|
|
550
|
-
toast: true,
|
|
551
|
-
icon: type,
|
|
552
|
-
title: msg,
|
|
553
|
-
position: this.cfg.toast.position || 'top-end',
|
|
554
|
-
showConfirmButton: false,
|
|
555
|
-
timer: this.cfg.toast.timer || 3000,
|
|
556
|
-
timerProgressBar: this.cfg.toast.timerProgressBar ?? true,
|
|
557
|
-
});
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
static _defaults = {
|
|
561
|
-
translations: {
|
|
562
|
-
loading: 'Loading...',
|
|
563
|
-
networkError: 'Network error.',
|
|
564
|
-
unknownError: 'Something went wrong.',
|
|
565
|
-
success: 'Success!',
|
|
566
|
-
error: 'There was a problem.',
|
|
567
|
-
},
|
|
568
|
-
};
|
|
1
|
+
/**
|
|
2
|
+
* DSForm (with DSAlert integration)
|
|
3
|
+
*
|
|
4
|
+
* A modern form handler that includes:
|
|
5
|
+
* - Integration with DSAlert for Toasts and Modals.
|
|
6
|
+
* - AJAX submission via 'fetch' | 'axios' | 'xhr'.
|
|
7
|
+
* - Laravel-style error mapping (dot/bracket notation).
|
|
8
|
+
* - Lifecycle hooks (onBeforeSubmit, onSubmit, onSuccess, onError, onComplete).
|
|
9
|
+
* - Automatic disable/loading states.
|
|
10
|
+
* - Data injection before submit.
|
|
11
|
+
*/
|
|
12
|
+
import { DSAlert } from './DSAlert.js';
|
|
13
|
+
|
|
14
|
+
export class DSForm {
|
|
15
|
+
/**
|
|
16
|
+
* @param {Object} config - Configuration object.
|
|
17
|
+
* @param {HTMLFormElement|string} config.form - Form element or selector.
|
|
18
|
+
* @param {string} [config.url] - Submit URL (defaults to form.action or current url).
|
|
19
|
+
* @param {'post'|'put'} [config.method] - HTTP method.
|
|
20
|
+
* @param {Array<string|HTMLElement>} [config.triggers] - External triggers (selectors/elements).
|
|
21
|
+
* @param {'fetch'|'axios'|'xhr'} [config.requestLib] - Preferred request lib; gracefully falls back.
|
|
22
|
+
* @param {Object} [config.headers] - Extra headers.
|
|
23
|
+
* @param {Object|Function} [config.additionalData] - Extra data merged before submit.
|
|
24
|
+
* @param {Array<string>} [config.disableSelectors] - Extra selectors to disable during submit.
|
|
25
|
+
* @param {Array<string>} [config.excludeEnableSelectors] - Keep these disabled after complete.
|
|
26
|
+
* @param {string} [config.primaryButtonSelector='button[type="submit"]'] - For default loading target.
|
|
27
|
+
* @param {(Function|string)} [config.loadingTemplate] - Function/Selector/Raw HTML for loading state.
|
|
28
|
+
* @param {Object} [config.translations] - i18n map for user texts.
|
|
29
|
+
* @param {Object} [config.toast] - Toast options { enabled, position, timer, timerProgressBar }.
|
|
30
|
+
* @param {boolean} [config.autoRedirect=true] - Follow payload.redirect if present.
|
|
31
|
+
* @param {boolean} [config.disableOnSuccess=false] - Keep all controls disabled if the request succeeds.
|
|
32
|
+
* @param {Function} [config.onBeforeSubmit]
|
|
33
|
+
* @param {Function} [config.onSubmit]
|
|
34
|
+
* @param {Function} [config.onSuccess]
|
|
35
|
+
* @param {Function} [config.onError]
|
|
36
|
+
* @param {Function} [config.onComplete]
|
|
37
|
+
* @param {string} [config.successTitle] - Title for success toast (overrides payload.message).
|
|
38
|
+
* @param {string} [config.errorTitle] - Title for error toast (overrides payload.message).
|
|
39
|
+
*/
|
|
40
|
+
constructor(config = {}) {
|
|
41
|
+
const defaults = {
|
|
42
|
+
requestLib: 'fetch',
|
|
43
|
+
primaryButtonSelector: 'button[type="submit"]',
|
|
44
|
+
translations: {
|
|
45
|
+
loading: 'Loading...',
|
|
46
|
+
networkError: 'Network error.',
|
|
47
|
+
unknownError: 'Something went wrong.',
|
|
48
|
+
success: 'Success!',
|
|
49
|
+
error: 'There was a problem.'
|
|
50
|
+
},
|
|
51
|
+
// Removed useSwal, added standard defaults compatible with DSAlert
|
|
52
|
+
toast: { enabled: false, position: 'top-end', timer: 3000, timerProgressBar: true },
|
|
53
|
+
autoRedirect: true,
|
|
54
|
+
disableOnSuccess: false,
|
|
55
|
+
successTitle: null,
|
|
56
|
+
errorTitle: null,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
this.cfg = { ...defaults, ...config };
|
|
60
|
+
|
|
61
|
+
this.form = typeof this.cfg.form === 'string' ? document.querySelector(this.cfg.form) : this.cfg.form;
|
|
62
|
+
if (!this.form) throw new Error('DSForm: form not found.');
|
|
63
|
+
|
|
64
|
+
this.url = this.cfg.url || this.form.getAttribute('action') || window.location.href;
|
|
65
|
+
this.method = (this.cfg.method || this.form.getAttribute('method') || 'post').toLowerCase();
|
|
66
|
+
|
|
67
|
+
this.triggers = (this.cfg.triggers || [])
|
|
68
|
+
.map(t => (typeof t === 'string' ? document.querySelector(t) : t))
|
|
69
|
+
.filter(Boolean);
|
|
70
|
+
|
|
71
|
+
this._activeTrigger = null;
|
|
72
|
+
this._disabledEls = new Set();
|
|
73
|
+
|
|
74
|
+
// Mini event emitter
|
|
75
|
+
this._listeners = {};
|
|
76
|
+
|
|
77
|
+
// Bind handlers once (per instance)
|
|
78
|
+
this._onFormSubmit = this._onFormSubmit.bind(this);
|
|
79
|
+
this._onExternalTrigger = this._onExternalTrigger.bind(this);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Attach listeners to form and external triggers */
|
|
83
|
+
bind() {
|
|
84
|
+
this.form.addEventListener('submit', this._onFormSubmit);
|
|
85
|
+
for (const el of this.triggers) el.addEventListener('click', this._onExternalTrigger);
|
|
86
|
+
return this;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Detach listeners */
|
|
90
|
+
unbind() {
|
|
91
|
+
this.form.removeEventListener('submit', this._onFormSubmit);
|
|
92
|
+
for (const el of this.triggers) el.removeEventListener('click', this._onExternalTrigger);
|
|
93
|
+
return this;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Emitter: subscribe to internal events (and mirrored DOM CustomEvents) */
|
|
97
|
+
on(event, handler) {
|
|
98
|
+
if (!this._listeners[event]) this._listeners[event] = new Set();
|
|
99
|
+
this._listeners[event].add(handler);
|
|
100
|
+
return this;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Emitter: unsubscribe */
|
|
104
|
+
off(event, handler) {
|
|
105
|
+
if (this._listeners[event]) this._listeners[event].delete(handler);
|
|
106
|
+
return this;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Programmatically submit (optional). Runs native validation via requestSubmit if supported. */
|
|
110
|
+
submit({ trigger = null } = {}) {
|
|
111
|
+
this._activeTrigger = trigger instanceof HTMLElement ? trigger : null;
|
|
112
|
+
if (typeof this.form.requestSubmit === 'function') {
|
|
113
|
+
const btn = this._activeTrigger?.type === 'submit' ? this._activeTrigger : this.form.querySelector(this.cfg.primaryButtonSelector);
|
|
114
|
+
btn ? this.form.requestSubmit(btn) : this.form.requestSubmit();
|
|
115
|
+
} else {
|
|
116
|
+
this.form.dispatchEvent(new Event('submit', { cancelable: true, bubbles: true }));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Public helper to merge extra data into request payload creation pipeline */
|
|
121
|
+
addData(extra = {}) {
|
|
122
|
+
if (typeof this.cfg.additionalData === 'function') {
|
|
123
|
+
const prev = this.cfg.additionalData;
|
|
124
|
+
this.cfg.additionalData = (data) => ({ ...prev(data), ...extra });
|
|
125
|
+
} else if (this.cfg.additionalData && typeof this.cfg.additionalData === 'object') {
|
|
126
|
+
this.cfg.additionalData = { ...this.cfg.additionalData, ...extra };
|
|
127
|
+
} else {
|
|
128
|
+
this.cfg.additionalData = { ...extra };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ================= Internal =================
|
|
133
|
+
|
|
134
|
+
_onExternalTrigger(e) {
|
|
135
|
+
e.preventDefault();
|
|
136
|
+
const trigger = e.currentTarget;
|
|
137
|
+
this.submit({ trigger });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async _onFormSubmit(e) {
|
|
141
|
+
e.preventDefault();
|
|
142
|
+
|
|
143
|
+
// Reset field errors each submit
|
|
144
|
+
this._resetErrors();
|
|
145
|
+
|
|
146
|
+
const { body, isFormData, jsonSnapshot } = this._buildBody();
|
|
147
|
+
const headers = this._buildHeaders(isFormData);
|
|
148
|
+
const controller = new AbortController();
|
|
149
|
+
|
|
150
|
+
// Hook: before
|
|
151
|
+
try { this.cfg.onBeforeSubmit?.({ formData: isFormData ? body : null, json: isFormData ? null : jsonSnapshot }); } catch (err) { console.warn('onBeforeSubmit error:', err); }
|
|
152
|
+
|
|
153
|
+
// Disable UI + loading
|
|
154
|
+
const loadingText = this._t('loading');
|
|
155
|
+
this._disableAll(loadingText);
|
|
156
|
+
|
|
157
|
+
this._emit('submit:start', { controller });
|
|
158
|
+
try { this.cfg.onSubmit?.({ controller }); } catch (err) { console.warn('onSubmit error:', err); }
|
|
159
|
+
|
|
160
|
+
let ok = false; let response, data;
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
({ response, data } = await this._sendRequest({ body, headers, signal: controller.signal, isFormData }));
|
|
164
|
+
ok = response.ok;
|
|
165
|
+
|
|
166
|
+
if (ok) {
|
|
167
|
+
this._emit('success', { response, data });
|
|
168
|
+
try { this.cfg.onSuccess?.({ response, data }); } catch (err) { console.warn('onSuccess error:', err); }
|
|
169
|
+
this._toast(this.cfg.successTitle || data?.message || this._t('success'), 'success');
|
|
170
|
+
if (this.cfg.autoRedirect) {
|
|
171
|
+
const payload = data?.data ?? data; // Laravel style
|
|
172
|
+
const url = payload?.redirect || payload?.url;
|
|
173
|
+
if (url) window.location.assign(url);
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
176
|
+
this._renderErrors(data);
|
|
177
|
+
this._emit('error', { response, data });
|
|
178
|
+
try { this.cfg.onError?.({ response, data }); } catch (err) { console.warn('onError error:', err); }
|
|
179
|
+
this._toast(this.cfg.errorTitle || data?.message || this._t('unknownError'), 'error');
|
|
180
|
+
}
|
|
181
|
+
} catch (err) {
|
|
182
|
+
console.error(err);
|
|
183
|
+
this._renderGeneralError(this._t('networkError'));
|
|
184
|
+
this._emit('error', { response: null, data: null, error: err });
|
|
185
|
+
try { this.cfg.onError?.({ response: null, data: null, error: err }); } catch (e2) { console.warn('onError error:', e2); }
|
|
186
|
+
this._toast(this._t('networkError'), 'error');
|
|
187
|
+
} finally {
|
|
188
|
+
// Only re-enable if not configured to keep disabled on success
|
|
189
|
+
if (!(this.cfg.disableOnSuccess && ok)) {
|
|
190
|
+
this._enableAll();
|
|
191
|
+
}
|
|
192
|
+
this._emit('submit:complete', { ok });
|
|
193
|
+
try { this.cfg.onComplete?.({ ok }); } catch (err) { console.warn('onComplete error:', err); }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
_buildBody() {
|
|
198
|
+
const formData = new FormData(this.form);
|
|
199
|
+
|
|
200
|
+
// Merge additional data
|
|
201
|
+
let extra = this.cfg.additionalData || null;
|
|
202
|
+
if (typeof extra === 'function') extra = extra(Object.fromEntries(formData.entries())) || null;
|
|
203
|
+
if (extra && typeof extra === 'object') {
|
|
204
|
+
for (const [k, v] of Object.entries(extra)) this._appendToFormData(formData, k, v);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const hasFile = Array.from(this.form.querySelectorAll('input[type="file"]')).some(i => i.files && i.files.length > 0);
|
|
208
|
+
const enctype = (this.form.getAttribute('enctype') || '').toLowerCase();
|
|
209
|
+
|
|
210
|
+
if (hasFile || enctype.includes('multipart')) {
|
|
211
|
+
return { body: formData, isFormData: true, jsonSnapshot: null };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const json = this._formDataToJSON(formData);
|
|
215
|
+
return { body: JSON.stringify(json), isFormData: false, jsonSnapshot: json };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
_appendToFormData(fd, key, val) {
|
|
219
|
+
if (val === undefined) return;
|
|
220
|
+
if (val === null) { fd.append(key, ''); return; }
|
|
221
|
+
if (Array.isArray(val)) { for (const item of val) fd.append(this._ensureArrayKey(key), item); return; }
|
|
222
|
+
if (typeof val === 'object') { for (const [k, v] of Object.entries(val)) this._appendToFormData(fd, `${key}[${k}]`, v); return; }
|
|
223
|
+
fd.append(key, String(val));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
_ensureArrayKey(key) { return key.endsWith('[]') ? key : `${key}[]`; }
|
|
227
|
+
|
|
228
|
+
_formDataToJSON(fd) {
|
|
229
|
+
const obj = {};
|
|
230
|
+
for (const [k, v] of fd.entries()) this._assignDeep(obj, k, v);
|
|
231
|
+
return obj;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
_assignDeep(obj, path, value) {
|
|
235
|
+
const parts = this._parsePath(path);
|
|
236
|
+
let cur = obj;
|
|
237
|
+
for (let i = 0; i < parts.length; i++) {
|
|
238
|
+
const p = parts[i];
|
|
239
|
+
const last = i === parts.length - 1;
|
|
240
|
+
if (last) {
|
|
241
|
+
if (p === '[]') {
|
|
242
|
+
if (!Array.isArray(cur)) throw new Error(`Path misuse at ${path}`);
|
|
243
|
+
cur.push(value);
|
|
244
|
+
} else if (p.endsWith('[]')) {
|
|
245
|
+
const key = p.slice(0, -2);
|
|
246
|
+
if (!Array.isArray(cur[key])) cur[key] = [];
|
|
247
|
+
cur[key].push(value);
|
|
248
|
+
} else {
|
|
249
|
+
cur[p] = value;
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
if (p.endsWith('[]')) {
|
|
253
|
+
const key = p.slice(0, -2);
|
|
254
|
+
if (!Array.isArray(cur[key])) cur[key] = [];
|
|
255
|
+
if (cur[key].length === 0 || typeof cur[key][cur[key].length - 1] !== 'object') cur[key].push({});
|
|
256
|
+
cur = cur[key][cur.length - 1];
|
|
257
|
+
} else {
|
|
258
|
+
if (!cur[p] || typeof cur[p] !== 'object') cur[p] = {};
|
|
259
|
+
cur = cur[p];
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
_parsePath(path) {
|
|
266
|
+
const parts = [];
|
|
267
|
+
|
|
268
|
+
// Check if path ends with [] (array notation like tags[], categories[])
|
|
269
|
+
const isArrayField = path.endsWith('[]');
|
|
270
|
+
|
|
271
|
+
// Remove trailing [] temporarily for parsing
|
|
272
|
+
let cleanPath = isArrayField ? path.slice(0, -2) : path;
|
|
273
|
+
|
|
274
|
+
// Parse the path: field[nested][key] -> [field, nested, key]
|
|
275
|
+
cleanPath.replace(/\]/g, '').split('[').forEach(p => {
|
|
276
|
+
if (p) parts.push(p);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// If it was an array field, add [] to the last part
|
|
280
|
+
if (isArrayField && parts.length > 0) {
|
|
281
|
+
parts[parts.length - 1] = parts[parts.length - 1] + '[]';
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return parts;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
_buildHeaders(isFormData) {
|
|
288
|
+
const headers = { ...(this.cfg.headers || {}) };
|
|
289
|
+
|
|
290
|
+
// CSRF from meta or hidden input
|
|
291
|
+
const csrf =
|
|
292
|
+
document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ||
|
|
293
|
+
this.form.querySelector('input[name="_token"]')?.value ||
|
|
294
|
+
null;
|
|
295
|
+
|
|
296
|
+
if (csrf) headers['X-CSRF-TOKEN'] = csrf;
|
|
297
|
+
if (!isFormData) headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
|
298
|
+
headers['Accept'] = headers['Accept'] || 'application/json';
|
|
299
|
+
return headers;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async _sendRequest({ body, headers, signal, isFormData }) {
|
|
303
|
+
const url = this.url;
|
|
304
|
+
const method = this.method;
|
|
305
|
+
const lib = (this.cfg.requestLib || 'fetch').toLowerCase();
|
|
306
|
+
|
|
307
|
+
// axios path
|
|
308
|
+
if (lib === 'axios' && window.axios) {
|
|
309
|
+
const resp = await window.axios({
|
|
310
|
+
url,
|
|
311
|
+
method,
|
|
312
|
+
data: isFormData ? body : JSON.parse(body || '{}'),
|
|
313
|
+
headers,
|
|
314
|
+
signal,
|
|
315
|
+
}).catch(err => (err.response ? err.response : Promise.reject(err)));
|
|
316
|
+
|
|
317
|
+
const ok = (resp.status >= 200 && resp.status < 300);
|
|
318
|
+
const data = resp.data && typeof resp.data === 'object' ? resp.data : {};
|
|
319
|
+
return { response: { ok, status: resp.status }, data };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// fetch path (default & axios fallback)
|
|
323
|
+
if (window.fetch) {
|
|
324
|
+
const resp = await fetch(url, { method, headers, body, signal, credentials: 'same-origin' });
|
|
325
|
+
let data = null; try { data = await resp.json(); } catch { data = {}; }
|
|
326
|
+
return { response: resp, data };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// xhr fallback
|
|
330
|
+
const { response, data } = await this._xhr({ url, method, headers, body, isFormData, signal });
|
|
331
|
+
return { response, data };
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
_xhr({ url, method, headers, body, isFormData, signal }) {
|
|
335
|
+
return new Promise((resolve, reject) => {
|
|
336
|
+
const xhr = new XMLHttpRequest();
|
|
337
|
+
xhr.open(method.toUpperCase(), url, true);
|
|
338
|
+
xhr.responseType = 'json';
|
|
339
|
+
for (const [k, v] of Object.entries(headers || {})) xhr.setRequestHeader(k, v);
|
|
340
|
+
xhr.onload = () => {
|
|
341
|
+
const ok = xhr.status >= 200 && xhr.status < 300;
|
|
342
|
+
resolve({ response: { ok, status: xhr.status }, data: xhr.response || {} });
|
|
343
|
+
};
|
|
344
|
+
xhr.onerror = () => reject(new Error('XHR network error'));
|
|
345
|
+
if (signal) signal.addEventListener('abort', () => { try { xhr.abort(); } catch { } reject(new Error('Aborted')); });
|
|
346
|
+
isFormData ? xhr.send(body) : xhr.send(body || null);
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
_disableAll(loadingText) {
|
|
351
|
+
this._disabledEls.clear();
|
|
352
|
+
|
|
353
|
+
// Disable all form elements
|
|
354
|
+
const elements = Array.from(this.form.querySelectorAll('input, select, textarea, button, a, [tabindex]'));
|
|
355
|
+
const extra = (this.cfg.disableSelectors || []).flatMap(sel => Array.from(document.querySelectorAll(sel)));
|
|
356
|
+
|
|
357
|
+
// Include the active trigger if present
|
|
358
|
+
const setToDisable = new Set([...elements, ...extra, ...(this._activeTrigger ? [this._activeTrigger] : [])]);
|
|
359
|
+
|
|
360
|
+
for (const el of setToDisable) {
|
|
361
|
+
if (!(el instanceof HTMLElement)) continue;
|
|
362
|
+
|
|
363
|
+
// Store previous state for restore
|
|
364
|
+
if (!el.dataset._dsfDisabled) el.dataset._dsfDisabled = el.hasAttribute('disabled') ? '1' : '0';
|
|
365
|
+
if (!el.dataset._dsfHTML) el.dataset._dsfHTML = el.innerHTML || '';
|
|
366
|
+
|
|
367
|
+
// Disable
|
|
368
|
+
if (el.tagName === 'A') {
|
|
369
|
+
el.dataset._dsfHref = el.getAttribute('href') || '';
|
|
370
|
+
el.setAttribute('href', 'javascript:void(0)');
|
|
371
|
+
el.classList.add('pointer-events-none');
|
|
372
|
+
}
|
|
373
|
+
el.setAttribute('disabled', 'disabled');
|
|
374
|
+
el.classList.add('disabled');
|
|
375
|
+
|
|
376
|
+
this._disabledEls.add(el);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Loading UI on the most relevant button/trigger
|
|
380
|
+
const targetBtn =
|
|
381
|
+
this._activeTrigger && this._isButtonLike(this._activeTrigger)
|
|
382
|
+
? this._activeTrigger
|
|
383
|
+
: this.form.querySelector(this.cfg.primaryButtonSelector);
|
|
384
|
+
|
|
385
|
+
if (targetBtn && targetBtn instanceof HTMLElement) {
|
|
386
|
+
targetBtn.innerHTML = this._resolveTemplate(this.cfg.loadingTemplate, loadingText) ||
|
|
387
|
+
`<span class="loading loading-bars loading-md"></span><span>${loadingText}</span>`;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
_enableAll() {
|
|
392
|
+
const exclude = new Set((this.cfg.excludeEnableSelectors || [])
|
|
393
|
+
.flatMap(sel => Array.from(document.querySelectorAll(sel))));
|
|
394
|
+
|
|
395
|
+
for (const el of this._disabledEls) {
|
|
396
|
+
if (!(el instanceof HTMLElement)) continue;
|
|
397
|
+
if (exclude.has(el)) continue;
|
|
398
|
+
|
|
399
|
+
// Restore href for <a>
|
|
400
|
+
if (el.tagName === 'A') {
|
|
401
|
+
if (el.dataset._dsfHref !== undefined) el.setAttribute('href', el.dataset._dsfHref);
|
|
402
|
+
el.classList.remove('pointer-events-none');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// Restore disabled state only if we disabled it
|
|
406
|
+
if (el.dataset._dsfDisabled === '0') {
|
|
407
|
+
el.removeAttribute('disabled');
|
|
408
|
+
el.classList.remove('disabled');
|
|
409
|
+
} else {
|
|
410
|
+
// It was already disabled before; keep it disabled
|
|
411
|
+
el.classList.add('disabled');
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Restore innerHTML if we changed it
|
|
415
|
+
if (el.dataset._dsfHTML !== undefined && this._isButtonLike(el)) {
|
|
416
|
+
el.innerHTML = el.dataset._dsfHTML;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Cleanup datasets
|
|
420
|
+
delete el.dataset._dsfDisabled;
|
|
421
|
+
delete el.dataset._dsfHTML;
|
|
422
|
+
delete el.dataset._dsfHref;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
this._disabledEls.clear();
|
|
426
|
+
this._activeTrigger = null;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
_isButtonLike(el) {
|
|
430
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
431
|
+
const tag = el.tagName.toLowerCase();
|
|
432
|
+
if (tag === 'button') return true;
|
|
433
|
+
if (tag === 'a') return true;
|
|
434
|
+
if (el.getAttribute('role') === 'button') return true;
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
_resolveTemplate(tpl, text) {
|
|
439
|
+
if (!tpl) return '';
|
|
440
|
+
if (typeof tpl === 'function') return tpl(text);
|
|
441
|
+
if (typeof tpl === 'string') {
|
|
442
|
+
if (tpl.trim().startsWith('#')) {
|
|
443
|
+
const node = document.querySelector(tpl);
|
|
444
|
+
return node ? node.innerHTML : '';
|
|
445
|
+
}
|
|
446
|
+
return tpl; // raw HTML
|
|
447
|
+
}
|
|
448
|
+
return '';
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
_resetErrors() {
|
|
452
|
+
const spans = this.form.querySelectorAll('.form-error[data-input]');
|
|
453
|
+
for (const s of spans) {
|
|
454
|
+
s.textContent = '';
|
|
455
|
+
s.classList.add('hidden');
|
|
456
|
+
}
|
|
457
|
+
const summary = this.form.querySelector('.form-error-summary');
|
|
458
|
+
if (summary) {
|
|
459
|
+
summary.textContent = '';
|
|
460
|
+
summary.classList.add('hidden');
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
_renderErrors(payload) {
|
|
465
|
+
if (!payload || typeof payload !== 'object') return;
|
|
466
|
+
const errors = payload.errors || {};
|
|
467
|
+
const message = payload.message || this.cfg.errorTitle || this._t('unknownError');
|
|
468
|
+
|
|
469
|
+
// Show per-field
|
|
470
|
+
for (const [key, arr] of Object.entries(errors)) {
|
|
471
|
+
const text = Array.isArray(arr) ? arr[0] : String(arr);
|
|
472
|
+
this._showErrorForKey(key, text);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// If no per-field target(s) found, show a summary (optional)
|
|
476
|
+
if (Object.keys(errors).length === 0) {
|
|
477
|
+
this._renderGeneralError(message);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
_showErrorForKey(key, text) {
|
|
482
|
+
// Accept both dot and bracket notations
|
|
483
|
+
const variants = new Set([
|
|
484
|
+
key,
|
|
485
|
+
key.replace(/\./g, '[') + ']',
|
|
486
|
+
key.replace(/\[(\w+)\]/g, '.$1').replace(/^\./, ''),
|
|
487
|
+
]);
|
|
488
|
+
let shown = false;
|
|
489
|
+
for (const v of variants) {
|
|
490
|
+
const span = this.form.querySelector(`.form-error[data-input="${CSS.escape(v)}"]`);
|
|
491
|
+
if (span) {
|
|
492
|
+
span.textContent = text;
|
|
493
|
+
span.classList.remove('hidden');
|
|
494
|
+
shown = true;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (!shown) {
|
|
498
|
+
const control = this.form.querySelector(`[name="${CSS.escape(key)}"]`) ||
|
|
499
|
+
this.form.querySelector(`[name="${CSS.escape(key.replace(/\./g, '[') + ']')}"]`);
|
|
500
|
+
if (control) {
|
|
501
|
+
let span = control.closest('label, .form-control, .form-group, div')?.querySelector('.form-error');
|
|
502
|
+
if (!span) {
|
|
503
|
+
span = document.createElement('span');
|
|
504
|
+
span.className = 'form-error text-error text-sm';
|
|
505
|
+
control.insertAdjacentElement('afterend', span);
|
|
506
|
+
}
|
|
507
|
+
span.textContent = text;
|
|
508
|
+
span.classList.remove('hidden');
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
_renderGeneralError(msg) {
|
|
514
|
+
const summary = this.form.querySelector('.form-error-summary');
|
|
515
|
+
if (summary) {
|
|
516
|
+
summary.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 shrink-0 stroke-current" fill="none" viewBox="0 0 24 24">\n' +
|
|
517
|
+
'<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />\n' +
|
|
518
|
+
'</svg>' + msg;
|
|
519
|
+
summary.classList.remove('hidden');
|
|
520
|
+
} else {
|
|
521
|
+
// Updated to use DSAlert instead of native alert
|
|
522
|
+
DSAlert.fire({
|
|
523
|
+
title: this._t('error'),
|
|
524
|
+
text: msg,
|
|
525
|
+
icon: 'error',
|
|
526
|
+
confirmButtonText: 'OK'
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
_emit(event, detail = {}) {
|
|
532
|
+
// Call registered handlers
|
|
533
|
+
(this._listeners[event] || new Set()).forEach(fn => { try { fn(detail); } catch (err) { console.warn(err); } });
|
|
534
|
+
// Mirror as DOM CustomEvent on the form element
|
|
535
|
+
this.form.dispatchEvent(new CustomEvent(`smartform:${event}`, { bubbles: true, detail }));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
_t(key) {
|
|
539
|
+
return (this.cfg.translations && this.cfg.translations[key]) || DSForm._defaults.translations[key] || key;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Toast helper using DSAlert */
|
|
543
|
+
_toast(message, type = 'info') {
|
|
544
|
+
if (!this.cfg.toast?.enabled) return;
|
|
545
|
+
|
|
546
|
+
const msg = message || (type === 'success' ? this._t('success') : this._t('error'));
|
|
547
|
+
|
|
548
|
+
// Map DSForm toast config to DSAlert config
|
|
549
|
+
DSAlert.fire({
|
|
550
|
+
toast: true,
|
|
551
|
+
icon: type,
|
|
552
|
+
title: msg,
|
|
553
|
+
position: this.cfg.toast.position || 'top-end',
|
|
554
|
+
showConfirmButton: false,
|
|
555
|
+
timer: this.cfg.toast.timer || 3000,
|
|
556
|
+
timerProgressBar: this.cfg.toast.timerProgressBar ?? true,
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
static _defaults = {
|
|
561
|
+
translations: {
|
|
562
|
+
loading: 'Loading...',
|
|
563
|
+
networkError: 'Network error.',
|
|
564
|
+
unknownError: 'Something went wrong.',
|
|
565
|
+
success: 'Success!',
|
|
566
|
+
error: 'There was a problem.',
|
|
567
|
+
},
|
|
568
|
+
};
|
|
569
569
|
}
|