@athoscommerce/snap-controller 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/LICENSE +21 -0
- package/README.md +117 -0
- package/dist/cjs/Abstract/AbstractController.d.ts +42 -0
- package/dist/cjs/Abstract/AbstractController.d.ts.map +1 -0
- package/dist/cjs/Abstract/AbstractController.js +305 -0
- package/dist/cjs/Autocomplete/AutocompleteController.d.ts +59 -0
- package/dist/cjs/Autocomplete/AutocompleteController.d.ts.map +1 -0
- package/dist/cjs/Autocomplete/AutocompleteController.js +1091 -0
- package/dist/cjs/Finder/FinderController.d.ts +15 -0
- package/dist/cjs/Finder/FinderController.d.ts.map +1 -0
- package/dist/cjs/Finder/FinderController.js +336 -0
- package/dist/cjs/Recommendation/RecommendationController.d.ts +27 -0
- package/dist/cjs/Recommendation/RecommendationController.d.ts.map +1 -0
- package/dist/cjs/Recommendation/RecommendationController.js +447 -0
- package/dist/cjs/Search/SearchController.d.ts +41 -0
- package/dist/cjs/Search/SearchController.d.ts.map +1 -0
- package/dist/cjs/Search/SearchController.js +993 -0
- package/dist/cjs/index.d.ts +7 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +29 -0
- package/dist/cjs/types.d.ts +87 -0
- package/dist/cjs/types.d.ts.map +1 -0
- package/dist/cjs/types.js +10 -0
- package/dist/cjs/utils/getParams.d.ts +3 -0
- package/dist/cjs/utils/getParams.d.ts.map +1 -0
- package/dist/cjs/utils/getParams.js +70 -0
- package/dist/cjs/utils/isClickWithinBannerLink.d.ts +2 -0
- package/dist/cjs/utils/isClickWithinBannerLink.d.ts.map +1 -0
- package/dist/cjs/utils/isClickWithinBannerLink.js +21 -0
- package/dist/cjs/utils/isClickWithinProductLink.d.ts +5 -0
- package/dist/cjs/utils/isClickWithinProductLink.d.ts.map +1 -0
- package/dist/cjs/utils/isClickWithinProductLink.js +25 -0
- package/dist/esm/Abstract/AbstractController.d.ts +42 -0
- package/dist/esm/Abstract/AbstractController.d.ts.map +1 -0
- package/dist/esm/Abstract/AbstractController.js +208 -0
- package/dist/esm/Autocomplete/AutocompleteController.d.ts +59 -0
- package/dist/esm/Autocomplete/AutocompleteController.d.ts.map +1 -0
- package/dist/esm/Autocomplete/AutocompleteController.js +882 -0
- package/dist/esm/Finder/FinderController.d.ts +15 -0
- package/dist/esm/Finder/FinderController.d.ts.map +1 -0
- package/dist/esm/Finder/FinderController.js +218 -0
- package/dist/esm/Recommendation/RecommendationController.d.ts +27 -0
- package/dist/esm/Recommendation/RecommendationController.d.ts.map +1 -0
- package/dist/esm/Recommendation/RecommendationController.js +342 -0
- package/dist/esm/Search/SearchController.d.ts +41 -0
- package/dist/esm/Search/SearchController.d.ts.map +1 -0
- package/dist/esm/Search/SearchController.js +795 -0
- package/dist/esm/index.d.ts +7 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/types.d.ts +87 -0
- package/dist/esm/types.d.ts.map +1 -0
- package/dist/esm/types.js +7 -0
- package/dist/esm/utils/getParams.d.ts +3 -0
- package/dist/esm/utils/getParams.d.ts.map +1 -0
- package/dist/esm/utils/getParams.js +67 -0
- package/dist/esm/utils/isClickWithinBannerLink.d.ts +2 -0
- package/dist/esm/utils/isClickWithinBannerLink.d.ts.map +1 -0
- package/dist/esm/utils/isClickWithinBannerLink.js +17 -0
- package/dist/esm/utils/isClickWithinProductLink.d.ts +5 -0
- package/dist/esm/utils/isClickWithinProductLink.d.ts.map +1 -0
- package/dist/esm/utils/isClickWithinProductLink.js +19 -0
- package/package.json +41 -0
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
import deepmerge from 'deepmerge';
|
|
2
|
+
import { StorageStore, ErrorType } from '@athoscommerce/snap-store-mobx';
|
|
3
|
+
import { AbstractController } from '../Abstract/AbstractController';
|
|
4
|
+
import { getSearchParams } from '../utils/getParams';
|
|
5
|
+
import { ControllerTypes } from '../types';
|
|
6
|
+
import { CLICK_DUPLICATION_TIMEOUT, isClickWithinProductLink } from '../utils/isClickWithinProductLink';
|
|
7
|
+
import { isClickWithinBannerLink } from '../utils/isClickWithinBannerLink';
|
|
8
|
+
const INPUT_ATTRIBUTE = 'ss-autocomplete-input';
|
|
9
|
+
export const INPUT_DELAY = 200;
|
|
10
|
+
const KEY_ENTER = 13;
|
|
11
|
+
const KEY_ESCAPE = 27;
|
|
12
|
+
const PARAM_FALLBACK_QUERY = 'fallbackQuery';
|
|
13
|
+
const defaultConfig = {
|
|
14
|
+
id: 'autocomplete',
|
|
15
|
+
selector: '',
|
|
16
|
+
action: '',
|
|
17
|
+
globals: {},
|
|
18
|
+
beacon: {
|
|
19
|
+
enabled: true,
|
|
20
|
+
},
|
|
21
|
+
settings: {
|
|
22
|
+
initializeFromUrl: true,
|
|
23
|
+
syncInputs: true,
|
|
24
|
+
serializeForm: false,
|
|
25
|
+
facets: {
|
|
26
|
+
trim: true,
|
|
27
|
+
pinFiltered: true,
|
|
28
|
+
},
|
|
29
|
+
redirects: {
|
|
30
|
+
merchandising: true,
|
|
31
|
+
singleResult: false,
|
|
32
|
+
},
|
|
33
|
+
bind: {
|
|
34
|
+
input: true,
|
|
35
|
+
submit: true,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
export class AutocompleteController extends AbstractController {
|
|
40
|
+
constructor(config, { client, store, urlManager, eventManager, profiler, logger, tracker }, context) {
|
|
41
|
+
super(config, { client, store, urlManager, eventManager, profiler, logger, tracker }, context);
|
|
42
|
+
this.type = ControllerTypes.autocomplete;
|
|
43
|
+
this.events = {};
|
|
44
|
+
this.track = {
|
|
45
|
+
banner: {
|
|
46
|
+
impression: (_banner) => {
|
|
47
|
+
if (!_banner) {
|
|
48
|
+
this.log.warn('No banner provided to track.banner.impression');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const { responseId, uid } = _banner;
|
|
52
|
+
if (!this.events[responseId]) {
|
|
53
|
+
this.log.warn('No responseId found in controller, ensure correct controller is used');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
else if (this.events?.[responseId]?.banner?.[uid]?.impression) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const banner = { uid };
|
|
60
|
+
const data = {
|
|
61
|
+
responseId,
|
|
62
|
+
banners: [banner],
|
|
63
|
+
results: [],
|
|
64
|
+
};
|
|
65
|
+
this.eventManager.fire('track.banner.impression', { controller: this, product: { uid }, trackEvent: data });
|
|
66
|
+
this.config.beacon?.enabled && this.tracker.events.autocomplete.impression({ data, siteId: this.config.globals?.siteId });
|
|
67
|
+
this.events[responseId].banner[uid] = this.events[responseId].banner[uid] || {};
|
|
68
|
+
this.events[responseId].banner[uid].impression = true;
|
|
69
|
+
},
|
|
70
|
+
click: (e, banner) => {
|
|
71
|
+
if (!banner) {
|
|
72
|
+
this.log.warn('No banner provided to track.banner.click');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const { responseId, uid } = banner;
|
|
76
|
+
if (!this.events[responseId]) {
|
|
77
|
+
this.log.warn('No responseId found in controller, ensure correct controller is used');
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (isClickWithinBannerLink(e)) {
|
|
81
|
+
if (this.events?.[responseId]?.banner[uid]?.clickThrough) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
this.track.banner.clickThrough(e, banner);
|
|
85
|
+
this.events[responseId].banner[uid] = this.events[responseId].banner[uid] || {};
|
|
86
|
+
this.events[responseId].banner[uid].clickThrough = true;
|
|
87
|
+
setTimeout(() => {
|
|
88
|
+
this.events[responseId].banner[uid].clickThrough = false;
|
|
89
|
+
}, CLICK_DUPLICATION_TIMEOUT);
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
clickThrough: (e, { uid, responseId }) => {
|
|
93
|
+
if (!uid) {
|
|
94
|
+
this.log.warn('No banner uid provided to track.banner.clickThrough');
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!this.events[responseId]) {
|
|
98
|
+
this.log.warn('No responseId found in controller, ensure correct controller is used');
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const banner = { uid };
|
|
102
|
+
const data = {
|
|
103
|
+
responseId,
|
|
104
|
+
banners: [banner],
|
|
105
|
+
};
|
|
106
|
+
this.eventManager.fire('track.banner.clickThrough', { controller: this, event: e, product: { uid }, trackEvent: data });
|
|
107
|
+
this.config.beacon?.enabled && this.tracker.events.autocomplete.clickThrough({ data, siteId: this.config.globals?.siteId });
|
|
108
|
+
this.events[responseId].banner[uid] = this.events[responseId].banner[uid] || {};
|
|
109
|
+
this.events[responseId].banner[uid].clickThrough = true;
|
|
110
|
+
setTimeout(() => {
|
|
111
|
+
this.events[responseId].banner[uid].clickThrough = false;
|
|
112
|
+
}, CLICK_DUPLICATION_TIMEOUT);
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
product: {
|
|
116
|
+
clickThrough: (e, result) => {
|
|
117
|
+
if (!result) {
|
|
118
|
+
this.log.warn('No result provided to track.product.clickThrough');
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const responseId = result.responseId;
|
|
122
|
+
if (!this.events[responseId]) {
|
|
123
|
+
this.log.warn('No responseId found in controller, ensure correct controller is used');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const type = (['product', 'banner'].includes(result.type) ? result.type : 'product');
|
|
127
|
+
const item = {
|
|
128
|
+
type,
|
|
129
|
+
uid: result.id ? '' + result.id : '',
|
|
130
|
+
...(type === 'product'
|
|
131
|
+
? {
|
|
132
|
+
parentId: result.mappings.core?.parentId ? '' + result.mappings.core?.parentId : '',
|
|
133
|
+
sku: result.mappings.core?.sku ? '' + result.mappings.core?.sku : undefined,
|
|
134
|
+
}
|
|
135
|
+
: {}),
|
|
136
|
+
};
|
|
137
|
+
const data = {
|
|
138
|
+
responseId,
|
|
139
|
+
results: [item],
|
|
140
|
+
};
|
|
141
|
+
this.eventManager.fire('track.product.clickThrough', { controller: this, event: e, product: result, trackEvent: data });
|
|
142
|
+
this.config.beacon?.enabled && this.tracker.events.autocomplete.clickThrough({ data, siteId: this.config.globals?.siteId });
|
|
143
|
+
},
|
|
144
|
+
click: (e, result) => {
|
|
145
|
+
if (!result) {
|
|
146
|
+
this.log.warn('No result provided to track.product.click');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const responseId = result.responseId;
|
|
150
|
+
if (!this.events[responseId]) {
|
|
151
|
+
this.log.warn('No responseId found in controller, ensure correct controller is used');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
if (result.type === 'banner' && isClickWithinBannerLink(e)) {
|
|
155
|
+
if (this.events?.[responseId]?.product[result.id]?.inlineBannerClickThrough) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
this.track.product.clickThrough(e, result);
|
|
159
|
+
this.events[responseId].product[result.id] = this.events[responseId].product[result.id] || {};
|
|
160
|
+
this.events[responseId].product[result.id].inlineBannerClickThrough = true;
|
|
161
|
+
setTimeout(() => {
|
|
162
|
+
this.events[responseId].product[result.id].inlineBannerClickThrough = false;
|
|
163
|
+
}, CLICK_DUPLICATION_TIMEOUT);
|
|
164
|
+
}
|
|
165
|
+
else if (isClickWithinProductLink(e, result)) {
|
|
166
|
+
if (this.events?.[responseId]?.product[result.id]?.productClickThrough) {
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
this.track.product.clickThrough(e, result);
|
|
170
|
+
this.events[responseId].product[result.id] = this.events[responseId].product[result.id] || {};
|
|
171
|
+
this.events[responseId].product[result.id].productClickThrough = true;
|
|
172
|
+
setTimeout(() => {
|
|
173
|
+
this.events[responseId].product[result.id].productClickThrough = false;
|
|
174
|
+
}, CLICK_DUPLICATION_TIMEOUT);
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
impression: (result) => {
|
|
178
|
+
if (!result) {
|
|
179
|
+
this.log.warn('No result provided to track.product.impression');
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const responseId = result.responseId;
|
|
183
|
+
if (!this.events[responseId]) {
|
|
184
|
+
this.log.warn('No responseId found in controller, ensure correct controller is used');
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
else if (this.events?.[responseId]?.product[result.id]?.impression) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const type = (['product', 'banner'].includes(result.type) ? result.type : 'product');
|
|
191
|
+
const item = {
|
|
192
|
+
type,
|
|
193
|
+
uid: result.id ? '' + result.id : '',
|
|
194
|
+
...(type === 'product'
|
|
195
|
+
? {
|
|
196
|
+
parentId: result.mappings.core?.parentId ? '' + result.mappings.core?.parentId : '',
|
|
197
|
+
sku: result.mappings.core?.sku ? '' + result.mappings.core?.sku : undefined,
|
|
198
|
+
}
|
|
199
|
+
: {}),
|
|
200
|
+
};
|
|
201
|
+
const data = {
|
|
202
|
+
responseId,
|
|
203
|
+
results: [item],
|
|
204
|
+
banners: [],
|
|
205
|
+
};
|
|
206
|
+
this.eventManager.fire('track.product.impression', { controller: this, product: result, trackEvent: data });
|
|
207
|
+
this.config.beacon?.enabled && this.tracker.events.autocomplete.impression({ data, siteId: this.config.globals?.siteId });
|
|
208
|
+
this.events[responseId].product[result.id] = this.events[responseId].product[result.id] || {};
|
|
209
|
+
this.events[responseId].product[result.id].impression = true;
|
|
210
|
+
},
|
|
211
|
+
addToCart: (result) => {
|
|
212
|
+
if (!result) {
|
|
213
|
+
this.log.warn('No result provided to track.product.addToCart');
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const responseId = result.responseId;
|
|
217
|
+
if (!this.events[responseId]) {
|
|
218
|
+
this.log.warn('No responseId found in controller, ensure correct controller is used');
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const product = {
|
|
222
|
+
parentId: result.id,
|
|
223
|
+
uid: result.id,
|
|
224
|
+
sku: result.mappings.core?.sku,
|
|
225
|
+
qty: result.quantity || 1,
|
|
226
|
+
price: Number(result.mappings.core?.price),
|
|
227
|
+
};
|
|
228
|
+
const data = {
|
|
229
|
+
responseId,
|
|
230
|
+
results: [product],
|
|
231
|
+
};
|
|
232
|
+
this.eventManager.fire('track.product.addToCart', { controller: this, product: result, trackEvent: data });
|
|
233
|
+
this.config.beacon?.enabled && this.tracker.events.autocomplete.addToCart({ data, siteId: this.config.globals?.siteId });
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
redirect: ({ redirectURL, responseId }) => {
|
|
237
|
+
if (!redirectURL) {
|
|
238
|
+
this.log.warn('No redirectURL provided to track.redirect');
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const data = {
|
|
242
|
+
responseId,
|
|
243
|
+
redirect: redirectURL,
|
|
244
|
+
};
|
|
245
|
+
this.eventManager.fire('track.redirect', { controller: this, redirectURL, trackEvent: data });
|
|
246
|
+
this.config.beacon?.enabled && this.tracker.events.autocomplete.redirect({ data, siteId: this.config.globals?.siteId });
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
this.handlers = {
|
|
250
|
+
input: {
|
|
251
|
+
enterKey: async (e) => {
|
|
252
|
+
if (e.keyCode == KEY_ENTER) {
|
|
253
|
+
const input = e.target;
|
|
254
|
+
let actionUrl = this.store.services.urlManager;
|
|
255
|
+
e.preventDefault();
|
|
256
|
+
// wait until loading is complete before submission
|
|
257
|
+
while (this.store.loading) {
|
|
258
|
+
await timeout(INPUT_DELAY);
|
|
259
|
+
}
|
|
260
|
+
// set fallbackQuery to the correctedQuery
|
|
261
|
+
if (this.store.search.correctedQuery) {
|
|
262
|
+
actionUrl = actionUrl?.set(PARAM_FALLBACK_QUERY, this.store.search.correctedQuery.string);
|
|
263
|
+
}
|
|
264
|
+
actionUrl = actionUrl?.set('query', input.value);
|
|
265
|
+
// wait for input delay
|
|
266
|
+
await timeout(INPUT_DELAY + 1);
|
|
267
|
+
try {
|
|
268
|
+
await this.eventManager.fire('beforeSubmit', {
|
|
269
|
+
controller: this,
|
|
270
|
+
input,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
catch (err) {
|
|
274
|
+
if (err?.message == 'cancelled') {
|
|
275
|
+
this.log.warn(`'beforeSubmit' middleware cancelled`);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
this.log.error(`error in 'beforeSubmit' middleware`);
|
|
280
|
+
console.error(err);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
window.location.href = actionUrl?.href || '';
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
escKey: (e) => {
|
|
287
|
+
if (e.keyCode == KEY_ESCAPE) {
|
|
288
|
+
e.target.blur();
|
|
289
|
+
this.setFocused();
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
focus: (e) => {
|
|
293
|
+
e.stopPropagation();
|
|
294
|
+
// timeout to ensure focus happens AFTER click
|
|
295
|
+
setTimeout(() => {
|
|
296
|
+
this.setFocused(e.target);
|
|
297
|
+
});
|
|
298
|
+
},
|
|
299
|
+
formSubmit: async (e) => {
|
|
300
|
+
const form = e.target;
|
|
301
|
+
const input = form.querySelector(`input[${INPUT_ATTRIBUTE}]`);
|
|
302
|
+
e.preventDefault();
|
|
303
|
+
// wait until loading is complete before submission
|
|
304
|
+
while (this.store.loading) {
|
|
305
|
+
await timeout(INPUT_DELAY);
|
|
306
|
+
}
|
|
307
|
+
// set fallbackQuery to the correctedQuery
|
|
308
|
+
if (this.store.search.correctedQuery) {
|
|
309
|
+
addHiddenFormInput(form, PARAM_FALLBACK_QUERY, this.store.search.correctedQuery.string);
|
|
310
|
+
}
|
|
311
|
+
// wait for input delay
|
|
312
|
+
await timeout(INPUT_DELAY + 1);
|
|
313
|
+
try {
|
|
314
|
+
await this.eventManager.fire('beforeSubmit', {
|
|
315
|
+
controller: this,
|
|
316
|
+
input,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
catch (err) {
|
|
320
|
+
if (err?.message == 'cancelled') {
|
|
321
|
+
this.log.warn(`'beforeSubmit' middleware cancelled`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
this.log.error(`error in 'beforeSubmit' middleware`);
|
|
326
|
+
console.error(err);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
form.submit();
|
|
330
|
+
},
|
|
331
|
+
formElementChange: (e) => {
|
|
332
|
+
const input = e.target;
|
|
333
|
+
const form = input?.form;
|
|
334
|
+
const searchInput = form?.querySelector(`input[${INPUT_ATTRIBUTE}]`);
|
|
335
|
+
if (form && searchInput && this.config.settings?.serializeForm) {
|
|
336
|
+
// get other form parameters (except the input)
|
|
337
|
+
const formParameters = getFormParameters(form, function (elem) {
|
|
338
|
+
return elem != searchInput;
|
|
339
|
+
});
|
|
340
|
+
// set parameters as globals
|
|
341
|
+
this.store.setService('urlManager', this.store.services.urlManager.reset().withGlobals(formParameters));
|
|
342
|
+
this.store.reset();
|
|
343
|
+
// rebuild trending terms with new UrlManager settings
|
|
344
|
+
if (this.config.settings?.trending?.enabled && this.config.settings?.trending?.limit && this.config.settings?.trending?.limit > 0) {
|
|
345
|
+
this.searchTrending();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
input: (e) => {
|
|
350
|
+
// return focus on input if it was lost
|
|
351
|
+
if (e.isTrusted && this.store.state.focusedInput !== e.target) {
|
|
352
|
+
this.setFocused(e.target);
|
|
353
|
+
}
|
|
354
|
+
const value = e.target.value;
|
|
355
|
+
// prevent search when value is unchanged or empty
|
|
356
|
+
if (((!this.store.state.input && !value) || this.store.state.input == value) && this.store.loaded) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
this.store.state.source = 'input';
|
|
360
|
+
this.store.state.input = value;
|
|
361
|
+
// remove merch redirect to prevent race condition
|
|
362
|
+
this.store.merchandising.redirect = '';
|
|
363
|
+
if (this.config?.settings?.syncInputs) {
|
|
364
|
+
const inputs = document.querySelectorAll(this.config.selector);
|
|
365
|
+
inputs.forEach((input) => {
|
|
366
|
+
input.value = value;
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
// TODO cancel any current requests?
|
|
370
|
+
clearTimeout(this.handlers.input.timeoutDelay);
|
|
371
|
+
const trendingResultsEnabled = this.store.trending?.length && this.config.settings?.trending?.enabled && this.config.settings?.trending?.showResults;
|
|
372
|
+
const historyResultsEnabled = this.store.history?.length && this.config.settings?.history?.enabled && this.config.settings?.history?.showResults;
|
|
373
|
+
this.handlers.input.timeoutDelay = setTimeout(() => {
|
|
374
|
+
if (!value) {
|
|
375
|
+
// there is no input value - reset state of store
|
|
376
|
+
this.store.reset();
|
|
377
|
+
// show results for trending or history (if configured) - trending has priority
|
|
378
|
+
if (trendingResultsEnabled) {
|
|
379
|
+
this.store.trending[0].preview();
|
|
380
|
+
}
|
|
381
|
+
else if (historyResultsEnabled) {
|
|
382
|
+
this.store.history[0].preview();
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
// no input - need to reset URL
|
|
386
|
+
this.urlManager.reset().go();
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
// new query in the input - trigger a new search via UrlManager
|
|
391
|
+
this.store.state.locks.terms.unlock();
|
|
392
|
+
this.store.state.locks.facets.unlock();
|
|
393
|
+
this.urlManager.set({ query: this.store.state.input }).go();
|
|
394
|
+
}
|
|
395
|
+
}, INPUT_DELAY);
|
|
396
|
+
},
|
|
397
|
+
timeoutDelay: undefined,
|
|
398
|
+
},
|
|
399
|
+
document: {
|
|
400
|
+
click: (e) => {
|
|
401
|
+
const inputs = document.querySelectorAll(this.config.selector);
|
|
402
|
+
// if the click is on an input or a form with inputs, stop propagation
|
|
403
|
+
if (Array.from(inputs).includes(e.target) ||
|
|
404
|
+
(e.target?.nodeName == 'FORM' && e.target.querySelectorAll(this.config.selector).length)) {
|
|
405
|
+
e.stopPropagation();
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
this.setFocused();
|
|
409
|
+
}
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
this.searchTrending = async (options) => {
|
|
414
|
+
let trending;
|
|
415
|
+
const storedTerms = this.storage.get('terms');
|
|
416
|
+
if (storedTerms && !options?.limit) {
|
|
417
|
+
// terms exist in storage, update store
|
|
418
|
+
trending = JSON.parse(storedTerms);
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
// query for trending terms, save to storage, update store
|
|
422
|
+
const trendingParams = {
|
|
423
|
+
limit: options?.limit || this.config.settings?.trending?.limit || 5,
|
|
424
|
+
};
|
|
425
|
+
const trendingProfile = this.profiler.create({ type: 'event', name: 'trending', context: trendingParams }).start();
|
|
426
|
+
trending = await this.client.trending(trendingParams);
|
|
427
|
+
trendingProfile.stop();
|
|
428
|
+
this.log.profile(trendingProfile);
|
|
429
|
+
if (trending?.trending.queries?.length) {
|
|
430
|
+
this.storage.set('terms', JSON.stringify(trending));
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
this.store.updateTrendingTerms(trending);
|
|
434
|
+
};
|
|
435
|
+
this.search = async () => {
|
|
436
|
+
try {
|
|
437
|
+
if (!this.initialized) {
|
|
438
|
+
await this.init();
|
|
439
|
+
}
|
|
440
|
+
// if urlManager has no query, there will be no need to get params and no query
|
|
441
|
+
if (!this.urlManager.state.query) {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
const params = this.params;
|
|
445
|
+
// if params have no query do not search
|
|
446
|
+
if (!params?.search?.query?.string) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
this.store.loading = true;
|
|
450
|
+
// clear the redirect URL until proper abort functionality is implemented
|
|
451
|
+
this.store.merchandising.redirect = '';
|
|
452
|
+
try {
|
|
453
|
+
await this.eventManager.fire('beforeSearch', {
|
|
454
|
+
controller: this,
|
|
455
|
+
request: params,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
catch (err) {
|
|
459
|
+
if (err?.message == 'cancelled') {
|
|
460
|
+
this.log.warn(`'beforeSearch' middleware cancelled`);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
this.log.error(`error in 'beforeSearch' middleware`);
|
|
465
|
+
throw err;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
const searchProfile = this.profiler.create({ type: 'event', name: 'search', context: params }).start();
|
|
469
|
+
const { meta, search } = await this.client.autocomplete(params);
|
|
470
|
+
searchProfile.stop();
|
|
471
|
+
this.log.profile(searchProfile);
|
|
472
|
+
const responseId = search.tracking.responseId;
|
|
473
|
+
this.events[responseId] = this.events[responseId] || { product: {}, banner: {} };
|
|
474
|
+
const previousResponseId = this.store.results[0]?.responseId;
|
|
475
|
+
if (previousResponseId && previousResponseId === responseId) {
|
|
476
|
+
const impressedResultIds = Object.keys(this.events[responseId].product || {}).filter((resultId) => this.events[responseId].product?.[resultId]?.impression);
|
|
477
|
+
this.events[responseId] = {
|
|
478
|
+
product: impressedResultIds.reduce((acc, resultId) => {
|
|
479
|
+
acc[resultId] = { impression: true };
|
|
480
|
+
return acc;
|
|
481
|
+
}, {}),
|
|
482
|
+
banner: this.events[responseId].banner,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
this.events[responseId] = { product: {}, banner: {} };
|
|
487
|
+
}
|
|
488
|
+
const afterSearchProfile = this.profiler.create({ type: 'event', name: 'afterSearch', context: params }).start();
|
|
489
|
+
try {
|
|
490
|
+
await this.eventManager.fire('afterSearch', {
|
|
491
|
+
controller: this,
|
|
492
|
+
request: params,
|
|
493
|
+
response: {
|
|
494
|
+
meta,
|
|
495
|
+
search,
|
|
496
|
+
},
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
catch (err) {
|
|
500
|
+
if (err?.message == 'cancelled') {
|
|
501
|
+
this.log.warn(`'afterSearch' middleware cancelled`);
|
|
502
|
+
afterSearchProfile.stop();
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
this.log.error(`error in 'afterSearch' middleware`);
|
|
507
|
+
throw err;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
afterSearchProfile.stop();
|
|
511
|
+
this.log.profile(afterSearchProfile);
|
|
512
|
+
// update the store
|
|
513
|
+
this.store.update({ meta, search });
|
|
514
|
+
const data = { responseId };
|
|
515
|
+
this.config.beacon?.enabled && this.tracker.events.autocomplete.render({ data, siteId: this.config.globals?.siteId });
|
|
516
|
+
const afterStoreProfile = this.profiler.create({ type: 'event', name: 'afterStore', context: params }).start();
|
|
517
|
+
try {
|
|
518
|
+
await this.eventManager.fire('afterStore', {
|
|
519
|
+
controller: this,
|
|
520
|
+
request: params,
|
|
521
|
+
response: {
|
|
522
|
+
meta,
|
|
523
|
+
search,
|
|
524
|
+
},
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
catch (err) {
|
|
528
|
+
if (err?.message == 'cancelled') {
|
|
529
|
+
this.log.warn(`'afterStore' middleware cancelled`);
|
|
530
|
+
afterStoreProfile.stop();
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
else {
|
|
534
|
+
this.log.error(`error in 'afterStore' middleware`);
|
|
535
|
+
throw err;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
afterStoreProfile.stop();
|
|
539
|
+
this.log.profile(afterStoreProfile);
|
|
540
|
+
}
|
|
541
|
+
catch (err) {
|
|
542
|
+
if (err) {
|
|
543
|
+
if (err.err && err.fetchDetails) {
|
|
544
|
+
switch (err.fetchDetails.status) {
|
|
545
|
+
case 429: {
|
|
546
|
+
this.store.error = {
|
|
547
|
+
code: 429,
|
|
548
|
+
type: ErrorType.WARNING,
|
|
549
|
+
message: 'Too many requests try again later',
|
|
550
|
+
};
|
|
551
|
+
break;
|
|
552
|
+
}
|
|
553
|
+
case 500: {
|
|
554
|
+
this.store.error = {
|
|
555
|
+
code: 500,
|
|
556
|
+
type: ErrorType.ERROR,
|
|
557
|
+
message: 'Invalid Search Request or Service Unavailable',
|
|
558
|
+
};
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
default: {
|
|
562
|
+
this.store.error = {
|
|
563
|
+
type: ErrorType.ERROR,
|
|
564
|
+
message: err.err.message,
|
|
565
|
+
};
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
this.log.error(this.store.error);
|
|
570
|
+
this.handleError(err.err, err.fetchDetails);
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
this.store.error = {
|
|
574
|
+
type: ErrorType.ERROR,
|
|
575
|
+
message: `Something went wrong... - ${err}`,
|
|
576
|
+
};
|
|
577
|
+
this.log.error(err);
|
|
578
|
+
this.handleError(err);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
finally {
|
|
583
|
+
this.store.loading = false;
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
this.addToCart = async (_products) => {
|
|
587
|
+
const products = typeof _products?.slice == 'function' ? _products.slice() : [_products];
|
|
588
|
+
if (!_products || products.length === 0) {
|
|
589
|
+
this.log.warn('No products provided to autocomplete controller.addToCart');
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
products.forEach((product) => {
|
|
593
|
+
this.track.product.addToCart(product);
|
|
594
|
+
});
|
|
595
|
+
if (products.length > 0) {
|
|
596
|
+
this.eventManager.fire('addToCart', { controller: this, products });
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
// deep merge config with defaults
|
|
600
|
+
this.config = deepmerge(defaultConfig, this.config);
|
|
601
|
+
// normalize trending config (old method requires only a limit to be set)
|
|
602
|
+
if (this.config.settings?.trending?.limit && typeof this.config.settings?.trending?.enabled === 'undefined') {
|
|
603
|
+
this.config.settings = {
|
|
604
|
+
...this.config.settings,
|
|
605
|
+
trending: {
|
|
606
|
+
enabled: true,
|
|
607
|
+
...this.config.settings.trending,
|
|
608
|
+
},
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
// normalize history config (old method requires only a limit to be set)
|
|
612
|
+
if (this.config.settings?.history?.limit && typeof this.config.settings?.history?.enabled === 'undefined') {
|
|
613
|
+
this.config.settings = {
|
|
614
|
+
...this.config.settings,
|
|
615
|
+
history: {
|
|
616
|
+
enabled: true,
|
|
617
|
+
...this.config.settings.history,
|
|
618
|
+
},
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
this.store.setConfig(this.config);
|
|
622
|
+
// get current search from url before detaching
|
|
623
|
+
if (this.config.settings.initializeFromUrl) {
|
|
624
|
+
this.store.state.input = this.urlManager.state.query;
|
|
625
|
+
// reset to force search on focus
|
|
626
|
+
// TODO: make a config setting for this
|
|
627
|
+
this.urlManager.reset().go();
|
|
628
|
+
}
|
|
629
|
+
// persist trending terms in local storage
|
|
630
|
+
this.storage = new StorageStore({
|
|
631
|
+
type: 'session',
|
|
632
|
+
key: `athos-controller-${this.config.id}`,
|
|
633
|
+
});
|
|
634
|
+
// add 'afterSearch' middleware
|
|
635
|
+
this.eventManager.on('afterSearch', async (ac, next) => {
|
|
636
|
+
await next();
|
|
637
|
+
// cancel search if no input or query doesn't match current urlState
|
|
638
|
+
if (ac.response.search.autocomplete?.query != ac.controller.urlManager.state.query) {
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
641
|
+
});
|
|
642
|
+
this.eventManager.on('beforeSubmit', async (ac, next) => {
|
|
643
|
+
await next();
|
|
644
|
+
const loading = ac.controller.store.loading;
|
|
645
|
+
if (loading)
|
|
646
|
+
return;
|
|
647
|
+
const inputState = ac.controller.store.state.input;
|
|
648
|
+
const redirectURL = ac.controller.store.merchandising?.redirect;
|
|
649
|
+
if (this.config?.settings?.redirects?.merchandising && inputState && redirectURL) {
|
|
650
|
+
this.track.redirect({ redirectURL, responseId: ac.controller.store.merchandising?.responseId });
|
|
651
|
+
window.location.href = redirectURL;
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
if (this.config?.settings?.redirects?.singleResult) {
|
|
655
|
+
const { results } = ac.controller.store;
|
|
656
|
+
//remove inline banners
|
|
657
|
+
const filteredResults = results.filter((result) => result.type == 'product');
|
|
658
|
+
const singleResultUrl = filteredResults.length === 1 && filteredResults[0].mappings.core?.url;
|
|
659
|
+
if (singleResultUrl) {
|
|
660
|
+
window.location.href = singleResultUrl;
|
|
661
|
+
return false;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
// attach config plugins and event middleware
|
|
666
|
+
this.use(this.config);
|
|
667
|
+
}
|
|
668
|
+
get params() {
|
|
669
|
+
const urlState = this.urlManager.state;
|
|
670
|
+
const params = deepmerge({ ...getSearchParams(urlState) }, this.config.globals || {});
|
|
671
|
+
const { userId, sessionId, pageLoadId, shopperId } = this.tracker.getContext();
|
|
672
|
+
params.tracking = params.tracking || {};
|
|
673
|
+
params.tracking.domain = window.location.href;
|
|
674
|
+
if (userId) {
|
|
675
|
+
params.tracking.userId = userId;
|
|
676
|
+
}
|
|
677
|
+
if (sessionId) {
|
|
678
|
+
params.tracking.sessionId = sessionId;
|
|
679
|
+
}
|
|
680
|
+
if (pageLoadId) {
|
|
681
|
+
params.tracking.pageLoadId = pageLoadId;
|
|
682
|
+
}
|
|
683
|
+
if (this.store.state.input) {
|
|
684
|
+
params.search = params.search || {};
|
|
685
|
+
params.search.input = this.store.state.input;
|
|
686
|
+
}
|
|
687
|
+
if (this.store.state.source) {
|
|
688
|
+
params.search = params.search || {};
|
|
689
|
+
params.search.source = this.store.state.source;
|
|
690
|
+
}
|
|
691
|
+
if (!this.config.globals?.personalization?.disabled) {
|
|
692
|
+
const cartItems = this.tracker.cookies.cart.get();
|
|
693
|
+
if (cartItems.length) {
|
|
694
|
+
params.personalization = params.personalization || {};
|
|
695
|
+
params.personalization.cart = cartItems.join(',');
|
|
696
|
+
}
|
|
697
|
+
const lastViewedItems = this.tracker.cookies.viewed.get();
|
|
698
|
+
if (lastViewedItems.length) {
|
|
699
|
+
params.personalization = params.personalization || {};
|
|
700
|
+
params.personalization.lastViewed = lastViewedItems.join(',');
|
|
701
|
+
}
|
|
702
|
+
if (shopperId) {
|
|
703
|
+
params.personalization = params.personalization || {};
|
|
704
|
+
params.personalization.shopper = shopperId;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
return params;
|
|
708
|
+
}
|
|
709
|
+
async setFocused(inputElement) {
|
|
710
|
+
if (this.store.state.focusedInput !== inputElement) {
|
|
711
|
+
this.store.state.focusedInput = inputElement;
|
|
712
|
+
// fire focusChange event
|
|
713
|
+
try {
|
|
714
|
+
try {
|
|
715
|
+
await this.eventManager.fire('focusChange', {
|
|
716
|
+
controller: this,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
catch (err) {
|
|
720
|
+
if (err?.message == 'cancelled') {
|
|
721
|
+
this.log.warn(`'focusChange' middleware cancelled`);
|
|
722
|
+
}
|
|
723
|
+
else {
|
|
724
|
+
this.log.error(`error in 'focusChange' middleware`);
|
|
725
|
+
throw err;
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
catch (err) {
|
|
730
|
+
if (err) {
|
|
731
|
+
console.error(err);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
inputElement?.dispatchEvent(new Event('input'));
|
|
736
|
+
}
|
|
737
|
+
reset() {
|
|
738
|
+
// reset input values and state
|
|
739
|
+
const inputs = document.querySelectorAll(this.config.selector);
|
|
740
|
+
inputs.forEach((input) => {
|
|
741
|
+
input.value = '';
|
|
742
|
+
});
|
|
743
|
+
this.store.reset();
|
|
744
|
+
}
|
|
745
|
+
unbind() {
|
|
746
|
+
const inputs = document.querySelectorAll(`input[${INPUT_ATTRIBUTE}]`);
|
|
747
|
+
inputs?.forEach((input) => {
|
|
748
|
+
input.removeEventListener('input', this.handlers.input.input);
|
|
749
|
+
input.removeEventListener('keydown', this.handlers.input.enterKey);
|
|
750
|
+
input.removeEventListener('keydown', this.handlers.input.escKey);
|
|
751
|
+
input.removeEventListener('focus', this.handlers.input.focus);
|
|
752
|
+
if (input.form) {
|
|
753
|
+
input.form.removeEventListener('submit', this.handlers.input.formSubmit);
|
|
754
|
+
unbindFormParameters(input.form, this.handlers.input.formElementChange);
|
|
755
|
+
}
|
|
756
|
+
});
|
|
757
|
+
document.removeEventListener('click', this.handlers.document.click);
|
|
758
|
+
}
|
|
759
|
+
async bind() {
|
|
760
|
+
if (!this.initialized) {
|
|
761
|
+
await this.init();
|
|
762
|
+
}
|
|
763
|
+
this.unbind();
|
|
764
|
+
const inputs = document.querySelectorAll(this.config.selector);
|
|
765
|
+
inputs.forEach((input) => {
|
|
766
|
+
input.setAttribute('spellcheck', 'false');
|
|
767
|
+
input.setAttribute('autocomplete', 'off');
|
|
768
|
+
input.setAttribute('autocorrect', 'off');
|
|
769
|
+
input.setAttribute('autocapitalize', 'none');
|
|
770
|
+
input.setAttribute(INPUT_ATTRIBUTE, '');
|
|
771
|
+
this.config.settings?.bind?.input && input.addEventListener('input', this.handlers.input.input);
|
|
772
|
+
if (this.config?.settings?.initializeFromUrl && !input.value && this.store.state.input) {
|
|
773
|
+
input.value = this.store.state.input;
|
|
774
|
+
}
|
|
775
|
+
input.addEventListener('focus', this.handlers.input.focus);
|
|
776
|
+
input.addEventListener('keydown', this.handlers.input.escKey);
|
|
777
|
+
const form = input.form;
|
|
778
|
+
let formActionUrl;
|
|
779
|
+
if (this.config.action) {
|
|
780
|
+
this.config.settings?.bind?.submit && input.addEventListener('keydown', this.handlers.input.enterKey);
|
|
781
|
+
formActionUrl = this.config.action;
|
|
782
|
+
}
|
|
783
|
+
else if (form) {
|
|
784
|
+
this.config.settings?.bind?.submit && form.addEventListener('submit', this.handlers.input.formSubmit);
|
|
785
|
+
formActionUrl = form.action || '';
|
|
786
|
+
// serializeForm will include additional form element in our urlManager as globals
|
|
787
|
+
if (this.config.settings?.serializeForm) {
|
|
788
|
+
bindFormParameters(form, this.handlers.input.formElementChange, function (elem) {
|
|
789
|
+
return elem != input;
|
|
790
|
+
});
|
|
791
|
+
const formParameters = getFormParameters(form, function (elem) {
|
|
792
|
+
return elem != input;
|
|
793
|
+
});
|
|
794
|
+
// set parameters as globals
|
|
795
|
+
this.store.setService('urlManager', this.urlManager.reset().withGlobals(formParameters));
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
// set the root URL on urlManager
|
|
799
|
+
if (formActionUrl) {
|
|
800
|
+
this.store.setService('urlManager', this.store.services.urlManager.withConfig((translatorConfig) => {
|
|
801
|
+
return {
|
|
802
|
+
...translatorConfig,
|
|
803
|
+
urlRoot: formActionUrl,
|
|
804
|
+
};
|
|
805
|
+
}));
|
|
806
|
+
}
|
|
807
|
+
// if the input is currently focused, trigger setFocues which will eventually trigger input - but not if loading
|
|
808
|
+
if (document.activeElement === input && !this.store.loading) {
|
|
809
|
+
this.setFocused(input);
|
|
810
|
+
}
|
|
811
|
+
});
|
|
812
|
+
// get trending terms - this is at the bottom because urlManager changes need to be in place before creating the store
|
|
813
|
+
if (this.config.settings?.trending?.enabled &&
|
|
814
|
+
this.config.settings?.trending?.limit &&
|
|
815
|
+
this.config.settings?.trending?.limit > 0 &&
|
|
816
|
+
!this.store.trending?.length) {
|
|
817
|
+
this.searchTrending();
|
|
818
|
+
}
|
|
819
|
+
// if we are not preventing via `disableClickOutside` setting
|
|
820
|
+
if (!this.config.settings?.disableClickOutside) {
|
|
821
|
+
document.addEventListener('click', this.handlers.document.click);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
function addHiddenFormInput(form, name, value) {
|
|
826
|
+
const inputElem = document.createElement('input');
|
|
827
|
+
inputElem.type = 'hidden';
|
|
828
|
+
inputElem.name = name;
|
|
829
|
+
inputElem.value = value;
|
|
830
|
+
// remove existing form element if it exists (prevent duplicates)
|
|
831
|
+
form.querySelector(`[type="hidden"][name="${name}"]`)?.remove();
|
|
832
|
+
// append form element
|
|
833
|
+
form.append(inputElem);
|
|
834
|
+
}
|
|
835
|
+
async function timeout(time) {
|
|
836
|
+
return new Promise((resolve) => {
|
|
837
|
+
window.setTimeout(resolve, time);
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
// for grabbing other parameters from the form and using them in UrlManager
|
|
841
|
+
const INPUT_TYPE_BLOCKLIST = ['file', 'reset', 'submit', 'button', 'image', 'password'];
|
|
842
|
+
function getFormParameters(form, filterFn) {
|
|
843
|
+
const parameters = {};
|
|
844
|
+
if (typeof form == 'object' && form.nodeName == 'FORM') {
|
|
845
|
+
for (let i = form.elements.length - 1; i >= 0; i--) {
|
|
846
|
+
const elem = form.elements[i];
|
|
847
|
+
if (typeof filterFn == 'function' && !filterFn(elem)) {
|
|
848
|
+
continue;
|
|
849
|
+
}
|
|
850
|
+
if (elem.name && !INPUT_TYPE_BLOCKLIST.includes(elem.type)) {
|
|
851
|
+
if ((elem.type != 'checkbox' && elem.type != 'radio') || elem.checked) {
|
|
852
|
+
parameters[elem.name] = elem.value;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
return parameters;
|
|
858
|
+
}
|
|
859
|
+
// this picks up changes to the form
|
|
860
|
+
function bindFormParameters(form, fn, filterFn) {
|
|
861
|
+
if (typeof form == 'object' && form.nodeName == 'FORM') {
|
|
862
|
+
for (let i = form.elements.length - 1; i >= 0; i--) {
|
|
863
|
+
const elem = form.elements[i];
|
|
864
|
+
if (typeof filterFn == 'function' && !filterFn(elem)) {
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
if (elem.name && !INPUT_TYPE_BLOCKLIST.includes(elem.type)) {
|
|
868
|
+
elem.addEventListener('change', fn);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
function unbindFormParameters(form, fn) {
|
|
874
|
+
if (typeof form == 'object' && form.nodeName == 'FORM') {
|
|
875
|
+
for (let i = form.elements.length - 1; i >= 0; i--) {
|
|
876
|
+
const elem = form.elements[i];
|
|
877
|
+
if (elem.name && !INPUT_TYPE_BLOCKLIST.includes(elem.type)) {
|
|
878
|
+
elem.removeEventListener('change', fn);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
}
|