-
-
Notifications
You must be signed in to change notification settings - Fork 3.7k
/
SwapController.ts
325 lines (282 loc) · 10.4 KB
/
SwapController.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
import { Controller } from '@hotwired/stimulus';
import { debounce } from '../utils/debounce';
/**
* Allow for an element to trigger an async query that will
* patch the results into a results DOM container. The query
* input can be the controlled element or the containing form.
* It supports the ability to update the URL with the query
* when processed or simply make a query based on a form's
* values.
*
* @example - A form that will update the results based on the form's input
* <div id="results"></div>
* <form
* data-controller="w-swap"
* data-action="input->w-swap#submitLazy"
* data-w-swap-src-value="path/to/search"
* data-w-swap-target-value="#results"
* >
* <input id="search" type="text" name="query" />
* <input id="filter" type="text" name="filter" />
* </form>
*
* @example - A single input that will update the results & the URL
* <div id="results"></div>
* <input
* id="search"
* type="text"
* name="q"
* data-controller="w-swap"
* data-action="input->w-swap#searchLazy"
* data-w-swap-src-value="path/to/search"
* data-w-swap-target-value="#listing-results"
* />
*
*/
export class SwapController extends Controller<
HTMLFormElement | HTMLInputElement
> {
static defaultClearParam = 'p';
static targets = ['input'];
static values = {
icon: { default: '', type: String },
loading: { default: false, type: Boolean },
reflect: { default: false, type: Boolean },
src: { default: '', type: String },
target: { default: '#listing-results', type: String },
wait: { default: 200, type: Number },
};
declare readonly hasInputTarget: boolean;
declare readonly hasTargetValue: boolean;
declare readonly hasUrlValue: boolean;
declare readonly inputTarget: HTMLInputElement;
declare iconValue: string;
declare loadingValue: boolean;
declare reflectValue: boolean;
declare srcValue: string;
declare targetValue: string;
declare waitValue: number;
/** Allow cancelling of in flight async request if disconnected */
abortController?: AbortController;
/** The related icon element to attach the spinner to */
iconElement?: SVGUseElement | null;
/** Debounced function to request a URL and then replace the DOM with the results */
replaceLazy?: { (...args: any[]): void; cancel(): void };
/** Debounced function to search results and then replace the DOM */
searchLazy?: { (...args: any[]): void; cancel(): void };
/** Debounced function to submit the serialised form and then replace the DOM */
submitLazy?: { (...args: any[]): void; cancel(): void };
connect() {
const formContainer = this.hasInputTarget
? this.inputTarget.form
: this.element;
this.srcValue =
this.srcValue || formContainer?.getAttribute('action') || '';
const target = this.target;
// set up icons
this.iconElement = null;
const iconContainer = (
this.hasInputTarget ? this.inputTarget : this.element
).parentElement;
this.iconElement = iconContainer?.querySelector('use') || null;
this.iconValue = this.iconElement?.getAttribute('href') || '';
// set up initial loading state (if set originally in the HTML)
this.loadingValue = false;
// set up debounced methods
this.replaceLazy = debounce(this.replace.bind(this), this.waitValue);
this.searchLazy = debounce(this.search.bind(this), this.waitValue);
this.submitLazy = debounce(this.submit.bind(this), this.waitValue);
// dispatch event for any initial action usage
this.dispatch('ready', { cancelable: false, target });
}
/**
* Element that receives the fetch result HTML output
*/
get target() {
const targetValue = this.targetValue;
const targetElement = document.querySelector(targetValue);
const foundTarget = targetElement && targetElement instanceof HTMLElement;
const hasValidUrlValue = !!this.srcValue;
const errors: string[] = [];
if (!foundTarget) {
errors.push(`Cannot find valid target element at "${targetValue}"`);
}
if (!hasValidUrlValue) {
errors.push(`Cannot find valid src URL value`);
}
if (errors.length) {
throw new Error(errors.join(', '));
}
return targetElement as HTMLElement;
}
/**
* Toggle the visual spinner icon if available and ensure content about
* to be replaced is flagged as busy.
*/
loadingValueChanged(isLoading: boolean, isLoadingPrevious) {
const target = isLoadingPrevious === undefined ? null : this.target; // ensure we avoid DOM interaction before connect
if (isLoading) {
target?.setAttribute('aria-busy', 'true');
this.iconElement?.setAttribute('href', '#icon-spinner');
} else {
target?.removeAttribute('aria-busy');
this.iconElement?.setAttribute('href', this.iconValue);
}
}
/**
* Perform a URL search param update based on the input's value with a comparison against the
* matching URL search params. Will replace the target element's content with the results
* of the async search request based on the query.
*
* Search will only be performed with the URL param value is different to the input value.
* Cleared params will be removed from the URL if present.
*
* `clear` can be provided as Event detail or action param to override the default of 'p'.
*/
search(
data?: CustomEvent<{ clear: string }> & {
params?: { clear?: string };
},
) {
/** Params to be cleared when updating the location (e.g. ['p'] for page). */
const clearParams = (
data?.detail?.clear ||
data?.params?.clear ||
(this.constructor as typeof SwapController).defaultClearParam
).split(' ');
const searchInput = this.hasInputTarget ? this.inputTarget : this.element;
const queryParam = searchInput.name;
const searchParams = new URLSearchParams(window.location.search);
const currentQuery = searchParams.get(queryParam) || '';
const newQuery = searchInput.value || '';
// only do the query if it has changed for trimmed queries
// for example - " " === "" and "first word " ==== "first word"
if (currentQuery.trim() === newQuery.trim()) return;
// Update search query param ('q') to the new value or remove if empty
if (newQuery) {
searchParams.set(queryParam, newQuery);
} else {
searchParams.delete(queryParam);
}
// clear any params (e.g. page/p) if needed
clearParams.forEach((param) => {
searchParams.delete(param);
});
const queryString = '?' + searchParams.toString();
const url = this.srcValue;
this.replace(url + queryString).then(() => {
window.history.replaceState(null, '', queryString);
});
}
/**
* Update the target element's content with the response from a request based on the input's form
* values serialised. Do not account for anything in the main location/URL, simply replace the content within
* the target element.
*/
submit() {
const form = (
this.hasInputTarget ? this.inputTarget.form : this.element
) as HTMLFormElement;
// serialise the form to a query string
// https://github.com/microsoft/TypeScript/issues/43797
const searchParams = new URLSearchParams(new FormData(form) as any);
const queryString = '?' + searchParams.toString();
const url = this.srcValue;
this.replace(url + queryString);
}
reflectParams(url: string) {
const params = new URL(url, window.location.href).searchParams;
const filteredParams = new URLSearchParams();
params.forEach((value, key) => {
// Check if the value is not empty after trimming white space
// and if the key is not a Wagtail internal param
if (value.trim() !== '' && !key.startsWith('_w_')) {
filteredParams.append(key, value);
}
});
const queryString = `?${filteredParams.toString()}`;
window.history.replaceState(null, '', queryString);
}
/**
* Abort any existing requests & set up new abort controller, then fetch and replace
* the HTML target with the new results.
* Cancel any in progress results request using the AbortController so that
* a faster response does not replace an in flight request.
*/
async replace(
data?:
| string
| (CustomEvent<{ url: string }> & { params?: { url?: string } }),
) {
const target = this.target;
/** Parse a request URL from the supplied param, as a string or inside a custom event */
const requestUrl =
(typeof data === 'string'
? data
: data?.detail?.url || data?.params?.url || '') || this.srcValue;
if (this.abortController) this.abortController.abort();
this.abortController = new AbortController();
const { signal } = this.abortController;
this.loadingValue = true;
const beginEvent = this.dispatch('begin', {
cancelable: true,
detail: { requestUrl },
target: this.target,
}) as CustomEvent<{ requestUrl: string }>;
if (beginEvent.defaultPrevented) return Promise.resolve();
return fetch(requestUrl, {
headers: { 'x-requested-with': 'XMLHttpRequest' },
signal,
})
.then((response) => {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
return response.text();
})
.then((results) => {
target.innerHTML = results;
if (this.reflectValue) {
const event = this.dispatch('reflect', {
cancelable: true,
detail: { requestUrl },
target,
});
if (!event.defaultPrevented) {
this.reflectParams(requestUrl);
}
}
this.dispatch('success', {
cancelable: false,
detail: { requestUrl, results },
target,
});
return results;
})
.catch((error) => {
if (signal.aborted) return;
this.dispatch('error', {
cancelable: false,
detail: { error, requestUrl },
target,
});
// eslint-disable-next-line no-console
console.error('Error fetching %s', requestUrl, error);
})
.finally(() => {
if (signal === this.abortController?.signal) {
this.loadingValue = false;
}
});
}
/**
* When disconnecting, ensure we reset any visual related state values and
* cancel any in-flight requests.
*/
disconnect() {
this.loadingValue = false;
this.replaceLazy?.cancel();
this.searchLazy?.cancel();
this.submitLazy?.cancel();
}
}