@brandup/ui-app 1.0.43 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -4
- package/dist/cjs/index.js +234 -148
- package/dist/cjs/index.js.map +1 -1
- package/dist/mjs/index.js +234 -148
- package/dist/mjs/index.js.map +1 -1
- package/dist/types.d.ts +151 -6
- package/package.json +3 -4
package/README.md
CHANGED
|
@@ -31,13 +31,14 @@ interface ExampleApplicationModel extends ApplicationModel {
|
|
|
31
31
|
export class ExampleApplication extends Application<ExampleApplicationModel> {
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
const
|
|
34
|
+
const appModel: ExampleApplicationModel = {};
|
|
35
|
+
|
|
36
|
+
const builder = new ApplicationBuilder<ExampleApplicationModel>(appModel);
|
|
35
37
|
builder
|
|
36
38
|
.useApp(ExampleApplication)
|
|
37
39
|
.useMiddleware(pages);
|
|
38
40
|
|
|
39
|
-
const
|
|
40
|
-
const app = builder.build<ExampleApplicationModel>({ basePath: "/" }, appModel);
|
|
41
|
+
const app = builder.build({ basePath: "/" });
|
|
41
42
|
|
|
42
43
|
app.run({ /*optional context params*/ })
|
|
43
44
|
.then(context: StartContext => { })
|
|
@@ -136,7 +137,7 @@ export interface PagesMiddleware {
|
|
|
136
137
|
export default () => new PagesMiddlewareImpl();
|
|
137
138
|
```
|
|
138
139
|
|
|
139
|
-
Example SPA navigation middleware: [example/src/frontend/middlewares/pages.ts](/example/src/frontend/middlewares/pages.ts)
|
|
140
|
+
Example SPA navigation middleware: [npm/brandup-ui-example/src/frontend/middlewares/pages.ts](/npm/brandup-ui-example/src/frontend/middlewares/pages.ts)
|
|
140
141
|
|
|
141
142
|
### Access to middleware
|
|
142
143
|
|
package/dist/cjs/index.js
CHANGED
|
@@ -3,23 +3,44 @@
|
|
|
3
3
|
var ui = require('@brandup/ui');
|
|
4
4
|
var uiHelpers = require('@brandup/ui-helpers');
|
|
5
5
|
|
|
6
|
+
/** Runs a chain of middlewares, invoking the named method on each in registration order. */
|
|
6
7
|
class MiddlewareInvoker {
|
|
8
|
+
/** Middleware handled by this invoker node. */
|
|
7
9
|
middleware;
|
|
8
10
|
__next;
|
|
11
|
+
__tail = this;
|
|
12
|
+
/**
|
|
13
|
+
* @param middleware Middleware handled by this invoker node.
|
|
14
|
+
*/
|
|
9
15
|
constructor(middleware) {
|
|
10
16
|
this.middleware = middleware;
|
|
11
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Append a middleware to the end of the chain.
|
|
20
|
+
* @param middleware Middleware to add.
|
|
21
|
+
*/
|
|
12
22
|
next(middleware) {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
this.__next = new MiddlewareInvoker(middleware);
|
|
23
|
+
const invoker = new MiddlewareInvoker(middleware);
|
|
24
|
+
this.__tail.__next = invoker;
|
|
25
|
+
this.__tail = invoker;
|
|
17
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Invoke the given method across the whole middleware chain.
|
|
29
|
+
* @param method Name of the middleware method to call (e.g. "start", "navigate").
|
|
30
|
+
* @param context Invocation context.
|
|
31
|
+
* @returns Promise resolved when the chain has completed.
|
|
32
|
+
*/
|
|
18
33
|
invoke(method, context) {
|
|
19
34
|
return this.__exec(method, context);
|
|
20
35
|
}
|
|
21
36
|
async __exec(method, context) {
|
|
22
|
-
|
|
37
|
+
let nextCalled = false;
|
|
38
|
+
const nextFunc = () => {
|
|
39
|
+
if (nextCalled)
|
|
40
|
+
throw new Error(`Middleware "${this.middleware.name}" called next() more than once for method "${method}".`);
|
|
41
|
+
nextCalled = true;
|
|
42
|
+
return this.__next ? this.__next.__exec(method, context) : Promise.resolve();
|
|
43
|
+
};
|
|
23
44
|
context.abort.throwIfAborted();
|
|
24
45
|
const methodFunc = this.middleware[method];
|
|
25
46
|
if (typeof methodFunc === "function") {
|
|
@@ -53,14 +74,18 @@ var constants = /*#__PURE__*/Object.freeze({
|
|
|
53
74
|
default: result
|
|
54
75
|
});
|
|
55
76
|
|
|
77
|
+
/** Unique name of the built-in state middleware. */
|
|
56
78
|
const STATE_MIDDLEWARE_NAME = "app-state";
|
|
79
|
+
/**
|
|
80
|
+
* Create the built-in state middleware that toggles loading/loaded/ready CSS state
|
|
81
|
+
* classes on the application element during start, load, navigation and submit.
|
|
82
|
+
* @returns The state middleware instance.
|
|
83
|
+
*/
|
|
57
84
|
const StateMiddlewareFactory = () => {
|
|
58
85
|
let counter = 0;
|
|
59
|
-
//let beginTime: number;
|
|
60
86
|
const begin = (context) => {
|
|
61
87
|
const prev = counter++;
|
|
62
88
|
if (prev === 0) {
|
|
63
|
-
//beginTime = Date.now();
|
|
64
89
|
context.app.element?.classList.remove(result.STATE_CLASS.LOADED);
|
|
65
90
|
context.app.element?.classList.add(result.STATE_CLASS.LOADING);
|
|
66
91
|
}
|
|
@@ -79,6 +104,9 @@ const StateMiddlewareFactory = () => {
|
|
|
79
104
|
begin(context);
|
|
80
105
|
try {
|
|
81
106
|
await next();
|
|
107
|
+
// on success the begin() is left open on purpose: the loading state
|
|
108
|
+
// must persist through "loaded" and the first navigation, which closes
|
|
109
|
+
// it (see the "first" branch in navigate). On error we close it here.
|
|
82
110
|
}
|
|
83
111
|
catch (reason) {
|
|
84
112
|
end(context);
|
|
@@ -101,12 +129,15 @@ const StateMiddlewareFactory = () => {
|
|
|
101
129
|
await next();
|
|
102
130
|
}
|
|
103
131
|
finally {
|
|
132
|
+
end(context); // close the begin() of this navigation
|
|
133
|
+
// the first navigation also closes the loading opened by start(),
|
|
134
|
+
// which intentionally leaves its begin() open until the page is rendered
|
|
104
135
|
if (context.source == "first")
|
|
105
136
|
end(context);
|
|
106
|
-
end(context);
|
|
107
137
|
}
|
|
108
138
|
},
|
|
109
139
|
submit: async (context, next) => {
|
|
140
|
+
begin(context);
|
|
110
141
|
try {
|
|
111
142
|
await next();
|
|
112
143
|
}
|
|
@@ -120,10 +151,15 @@ const StateMiddlewareFactory = () => {
|
|
|
120
151
|
};
|
|
121
152
|
};
|
|
122
153
|
|
|
154
|
+
/** Unique name of the built-in hyperlink middleware. */
|
|
123
155
|
const HYPERLINK_MIDDLEWARE_NAME = "app-hyperlink";
|
|
156
|
+
/**
|
|
157
|
+
* Create the built-in hyperlink middleware that intercepts clicks on application links
|
|
158
|
+
* (anchors with the `applink` class or elements with `data-nav-url`) and routes them through
|
|
159
|
+
* application navigation. Honors meta/ctrl-click and `target="_blank"` to open in a new tab.
|
|
160
|
+
* @returns The hyperlink middleware instance.
|
|
161
|
+
*/
|
|
124
162
|
const HyperLinkMiddlewareFactory = () => {
|
|
125
|
-
let __ctrlPressed = false;
|
|
126
|
-
const onKeyDownUp = (e) => __ctrlPressed = e.ctrlKey;
|
|
127
163
|
let onClick;
|
|
128
164
|
return {
|
|
129
165
|
name: HYPERLINK_MIDDLEWARE_NAME,
|
|
@@ -141,11 +177,9 @@ const HyperLinkMiddlewareFactory = () => {
|
|
|
141
177
|
if (elem.classList.contains(result.NavUrlClassName) || elem.hasAttribute(result.NavUrlAttributeName))
|
|
142
178
|
break;
|
|
143
179
|
}
|
|
144
|
-
if (elem === e.currentTarget)
|
|
145
|
-
return;
|
|
146
180
|
elem = elem.parentElement;
|
|
147
181
|
}
|
|
148
|
-
if (!elem ||
|
|
182
|
+
if (!elem || e.ctrlKey || e.metaKey || elem.getAttribute("target") === "_blank")
|
|
149
183
|
return;
|
|
150
184
|
e.preventDefault();
|
|
151
185
|
e.stopPropagation();
|
|
@@ -156,8 +190,12 @@ const HyperLinkMiddlewareFactory = () => {
|
|
|
156
190
|
url = elem.getAttribute("href");
|
|
157
191
|
else if (elem.hasAttribute(result.NavUrlAttributeName))
|
|
158
192
|
url = elem.getAttribute(result.NavUrlAttributeName);
|
|
159
|
-
else
|
|
160
|
-
|
|
193
|
+
else {
|
|
194
|
+
// matched an applink element with no resolvable url (malformed markup);
|
|
195
|
+
// swallow the click rather than throwing inside a global event listener
|
|
196
|
+
console.warn("Application hyperlink: clicked element has no navigation url.");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
161
199
|
if (elem.classList.contains(result.LoadingElementClass))
|
|
162
200
|
return;
|
|
163
201
|
elem.classList.add(result.LoadingElementClass);
|
|
@@ -171,18 +209,20 @@ const HyperLinkMiddlewareFactory = () => {
|
|
|
171
209
|
.catch(() => { })
|
|
172
210
|
.finally(() => elem.classList.remove(result.LoadingElementClass));
|
|
173
211
|
}, false);
|
|
174
|
-
window.addEventListener("keydown", onKeyDownUp, false);
|
|
175
|
-
window.addEventListener("keyup", onKeyDownUp, false);
|
|
176
212
|
},
|
|
177
213
|
stop: (_context, next) => {
|
|
178
214
|
window.removeEventListener("click", onClick, false);
|
|
179
|
-
window.removeEventListener("keydown", onKeyDownUp, false);
|
|
180
|
-
window.removeEventListener("keyup", onKeyDownUp, false);
|
|
181
215
|
return next();
|
|
182
216
|
}
|
|
183
217
|
};
|
|
184
218
|
};
|
|
185
219
|
|
|
220
|
+
/**
|
|
221
|
+
* Parse a url into its components relative to the application base path.
|
|
222
|
+
* @param basePath Application base path.
|
|
223
|
+
* @param url Url to parse. If null, the current `window.location` is used.
|
|
224
|
+
* @returns Parsed url parts.
|
|
225
|
+
*/
|
|
186
226
|
const parseUrl = (basePath, url) => {
|
|
187
227
|
const loc = window.location;
|
|
188
228
|
let origin = loc.origin;
|
|
@@ -214,7 +254,7 @@ const parseUrl = (basePath, url) => {
|
|
|
214
254
|
path = loc.pathname;
|
|
215
255
|
query = new URLSearchParams(url);
|
|
216
256
|
}
|
|
217
|
-
else if (
|
|
257
|
+
else if (/^https?:\/\//i.test(url)) {
|
|
218
258
|
const u = new URL(url);
|
|
219
259
|
if (u.origin != origin) {
|
|
220
260
|
origin = u.origin;
|
|
@@ -250,7 +290,7 @@ const parseUrl = (basePath, url) => {
|
|
|
250
290
|
path = path.substring(0, path.length - 1);
|
|
251
291
|
path = path.toLowerCase();
|
|
252
292
|
if (basePath) {
|
|
253
|
-
if (path.
|
|
293
|
+
if (path.startsWith(basePath.toLowerCase())) {
|
|
254
294
|
path = path.substring(basePath.length);
|
|
255
295
|
if (!path)
|
|
256
296
|
path = '/';
|
|
@@ -293,8 +333,10 @@ const extendQuery = (url, query) => {
|
|
|
293
333
|
query.forEach((v, k) => url.query.append(k, v.toString()));
|
|
294
334
|
}
|
|
295
335
|
else {
|
|
296
|
-
for (
|
|
336
|
+
for (const key in query) {
|
|
297
337
|
const value = query[key];
|
|
338
|
+
if (value === null || typeof value === "undefined")
|
|
339
|
+
continue;
|
|
298
340
|
if (!Array.isArray(value)) {
|
|
299
341
|
url.query.set(key, value);
|
|
300
342
|
}
|
|
@@ -316,6 +358,14 @@ const rebuildUrl = (parsedUrl) => {
|
|
|
316
358
|
parsedUrl.relative = relativeUrl;
|
|
317
359
|
parsedUrl.full = parsedUrl.hash ? `${parsedUrl.url}#${parsedUrl.hash}` : parsedUrl.url;
|
|
318
360
|
};
|
|
361
|
+
/**
|
|
362
|
+
* Build a relative url from a base path with optional path, query and hash.
|
|
363
|
+
* @param basePath Application base path.
|
|
364
|
+
* @param path Optional path appended to the base path.
|
|
365
|
+
* @param query Optional query parameters.
|
|
366
|
+
* @param hash Optional hash.
|
|
367
|
+
* @returns Relative url with base path.
|
|
368
|
+
*/
|
|
319
369
|
const buildUrl = (basePath, path, query, hash) => {
|
|
320
370
|
let url = basePath;
|
|
321
371
|
if (url == '/')
|
|
@@ -360,6 +410,7 @@ const buildUrl = (basePath, path, query, hash) => {
|
|
|
360
410
|
}
|
|
361
411
|
return url;
|
|
362
412
|
};
|
|
413
|
+
/** Url helper functions. */
|
|
363
414
|
var urlHelper = {
|
|
364
415
|
parseUrl,
|
|
365
416
|
extendQuery,
|
|
@@ -371,7 +422,9 @@ var url = /*#__PURE__*/Object.freeze({
|
|
|
371
422
|
default: urlHelper
|
|
372
423
|
});
|
|
373
424
|
|
|
425
|
+
/** Type name of the base {@link Application} class. */
|
|
374
426
|
const APP_TYPENAME = "brandup-ui-app";
|
|
427
|
+
/** Reason value thrown when a navigation is overridden (e.g. by a redirect). */
|
|
375
428
|
const NAV_OVERIDE_ERROR = "NavigationOveride";
|
|
376
429
|
/**
|
|
377
430
|
* Base application class.
|
|
@@ -388,8 +441,13 @@ class Application extends ui.UIElement {
|
|
|
388
441
|
__isRuned;
|
|
389
442
|
__middlewares = {};
|
|
390
443
|
__globalSubmit;
|
|
444
|
+
__onPopStateHandler;
|
|
391
445
|
__execNav; // current navigation invoking
|
|
392
446
|
__lastNav; // last success navigation
|
|
447
|
+
/**
|
|
448
|
+
* @param env Application environment.
|
|
449
|
+
* @param model Application model.
|
|
450
|
+
*/
|
|
393
451
|
constructor(env, model, ..._args) {
|
|
394
452
|
super();
|
|
395
453
|
this.env = env;
|
|
@@ -398,6 +456,7 @@ class Application extends ui.UIElement {
|
|
|
398
456
|
this.invoker = new MiddlewareInvoker(core);
|
|
399
457
|
this.__abort = new AbortController();
|
|
400
458
|
}
|
|
459
|
+
/** Type name of the application. */
|
|
401
460
|
get typeName() { return APP_TYPENAME; }
|
|
402
461
|
/** Current navigation context. */
|
|
403
462
|
get current() { return this.__lastNav?.context; }
|
|
@@ -410,25 +469,36 @@ class Application extends ui.UIElement {
|
|
|
410
469
|
this.__isInited = true;
|
|
411
470
|
this.onInitialize();
|
|
412
471
|
middlewares.forEach(middleware => {
|
|
413
|
-
|
|
472
|
+
const name = middleware.name;
|
|
414
473
|
if (this.__middlewares.hasOwnProperty(name))
|
|
415
474
|
throw new Error(`Middleware "${name}" already registered.`);
|
|
416
475
|
this.__middlewares[name] = middleware;
|
|
417
476
|
this.invoker.next(middleware);
|
|
418
477
|
});
|
|
419
478
|
}
|
|
420
|
-
/**
|
|
479
|
+
/**
|
|
480
|
+
* Initialize application instance. Override to register additional middlewares.
|
|
481
|
+
* Registers the built-in state and hyperlink middlewares by default.
|
|
482
|
+
*/
|
|
421
483
|
onInitialize() {
|
|
422
484
|
this.invoker.next(StateMiddlewareFactory());
|
|
423
485
|
this.invoker.next(HyperLinkMiddlewareFactory());
|
|
424
486
|
}
|
|
425
|
-
/**
|
|
487
|
+
/**
|
|
488
|
+
* Called at the beginning of application run, before middlewares start.
|
|
489
|
+
* Override to perform async setup work.
|
|
490
|
+
* @returns Promise resolved when starting work is complete.
|
|
491
|
+
*/
|
|
426
492
|
onStarting() { return Promise.resolve(); }
|
|
427
|
-
/**
|
|
493
|
+
/**
|
|
494
|
+
* Called after the application has fully started.
|
|
495
|
+
* Override to perform async work after start.
|
|
496
|
+
* @returns Promise resolved when post-start work is complete.
|
|
497
|
+
*/
|
|
428
498
|
onStared() { return Promise.resolve(); }
|
|
429
499
|
/**
|
|
430
|
-
* Get middleware by
|
|
431
|
-
* @param
|
|
500
|
+
* Get registered middleware by its unique name.
|
|
501
|
+
* @param name Unique name of middleware.
|
|
432
502
|
* @returns Middleware instance.
|
|
433
503
|
*/
|
|
434
504
|
middleware(name) {
|
|
@@ -467,7 +537,7 @@ class Application extends ui.UIElement {
|
|
|
467
537
|
await this.invoker.invoke("loaded", context);
|
|
468
538
|
console.info("app load success");
|
|
469
539
|
this.__abort.signal.throwIfAborted();
|
|
470
|
-
window.addEventListener("popstate", (e) => this.__onPopState(context, e));
|
|
540
|
+
window.addEventListener("popstate", this.__onPopStateHandler = (e) => this.__onPopState(context, e));
|
|
471
541
|
element.addEventListener("submit", this.__globalSubmit = (e) => {
|
|
472
542
|
const form = e.target;
|
|
473
543
|
if (!form.classList.contains(result.FormClassName))
|
|
@@ -476,23 +546,24 @@ class Application extends ui.UIElement {
|
|
|
476
546
|
this.__onSubmit({ form, button: e.submitter instanceof HTMLButtonElement ? e.submitter : null })
|
|
477
547
|
.catch(() => { });
|
|
478
548
|
}, false);
|
|
479
|
-
await this.onStared();
|
|
480
|
-
console.info("app runned");
|
|
481
549
|
}
|
|
482
550
|
catch (reason) {
|
|
483
551
|
console.error(`app run error: ${reason}`);
|
|
484
552
|
throw reason;
|
|
485
553
|
}
|
|
554
|
+
// Run the first navigation before onStared(): its navigate middleware always
|
|
555
|
+
// closes the startup loading state (in a finally, even on error), so the app
|
|
556
|
+
// can never get stuck "loading" if onStared throws.
|
|
486
557
|
try {
|
|
487
558
|
await this.nav({ data: context.data, abort: this.__abort.signal });
|
|
488
559
|
}
|
|
489
560
|
catch (reason) {
|
|
490
|
-
if (reason
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
}
|
|
494
|
-
throw reason;
|
|
561
|
+
if (reason !== NAV_OVERIDE_ERROR)
|
|
562
|
+
throw reason;
|
|
563
|
+
console.info(`app run nav overided`);
|
|
495
564
|
}
|
|
565
|
+
await this.onStared();
|
|
566
|
+
console.info("app runned");
|
|
496
567
|
return context;
|
|
497
568
|
}
|
|
498
569
|
/**
|
|
@@ -512,54 +583,19 @@ class Application extends ui.UIElement {
|
|
|
512
583
|
action = "first";
|
|
513
584
|
else {
|
|
514
585
|
const isChangedUrl = this.__lastNav?.context.url.toLowerCase() !== navUrl.url.toLowerCase();
|
|
515
|
-
const hasHash = !!
|
|
586
|
+
const hasHash = !!navUrl.hash;
|
|
516
587
|
if (isChangedUrl)
|
|
517
|
-
action = "url-change"; //
|
|
588
|
+
action = "url-change"; // url changed
|
|
518
589
|
else
|
|
519
590
|
action = hasHash ? "hash" : "url-no-change";
|
|
520
591
|
}
|
|
521
|
-
|
|
522
|
-
if (this.__execNav && this.__execNav.status === "work") {
|
|
523
|
-
parentNav = this.__execNav;
|
|
524
|
-
parentNav.abort.abort(NAV_OVERIDE_ERROR);
|
|
525
|
-
parentNav.context.overided = true;
|
|
526
|
-
}
|
|
527
|
-
const navIndex = parentNav ? parentNav.context.index + 1 : 1;
|
|
528
|
-
const navAbort = new AbortController();
|
|
529
|
-
const aborts = [this.__abort.signal, navAbort.signal];
|
|
530
|
-
if (abort)
|
|
531
|
-
aborts.push(abort);
|
|
532
|
-
const complextAbort = AbortSignal.any(aborts);
|
|
592
|
+
const base = this.__beginNav(abort);
|
|
533
593
|
const context = {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
source: isFirst ? "first" : "nav",
|
|
537
|
-
app: this,
|
|
538
|
-
abort: complextAbort,
|
|
539
|
-
current: this.__lastNav?.context,
|
|
540
|
-
parent: parentNav?.context,
|
|
541
|
-
overided: false,
|
|
542
|
-
action: action,
|
|
543
|
-
data,
|
|
544
|
-
url: navUrl.url,
|
|
545
|
-
origin: navUrl.origin,
|
|
546
|
-
pathAndQuery: navUrl.relative,
|
|
547
|
-
basePath: navUrl.basePath,
|
|
548
|
-
path: navUrl.path,
|
|
549
|
-
query: navUrl.query,
|
|
550
|
-
hash: navUrl.hash,
|
|
551
|
-
external: navUrl.external,
|
|
552
|
-
replace,
|
|
553
|
-
scope,
|
|
554
|
-
redirect: async (options) => {
|
|
555
|
-
complextAbort.throwIfAborted();
|
|
556
|
-
const result = await this.nav(options);
|
|
557
|
-
complextAbort.throwIfAborted();
|
|
558
|
-
return result;
|
|
559
|
-
}
|
|
594
|
+
...this.__createContext(base, navUrl, isFirst ? "first" : "nav", action, data, replace),
|
|
595
|
+
scope
|
|
560
596
|
};
|
|
561
|
-
const currentNav = { method: "navigate", context, abort: navAbort, status: "work" };
|
|
562
|
-
return await this.__execNavigate(currentNav, parentNav);
|
|
597
|
+
const currentNav = { method: "navigate", context, abort: base.navAbort, status: "work" };
|
|
598
|
+
return await this.__execNavigate(currentNav, base.parentNav);
|
|
563
599
|
}
|
|
564
600
|
/**
|
|
565
601
|
* Reload page with nav.
|
|
@@ -575,9 +611,14 @@ class Application extends ui.UIElement {
|
|
|
575
611
|
this.__abort.signal.throwIfAborted();
|
|
576
612
|
window.location.reload();
|
|
577
613
|
}
|
|
614
|
+
/**
|
|
615
|
+
* Destroy application: abort pending navigations, run the middleware "stop" chain and release listeners.
|
|
616
|
+
* @param contextData Stop context data.
|
|
617
|
+
* @returns Promise of the stop context.
|
|
618
|
+
*/
|
|
578
619
|
async destroy(contextData) {
|
|
579
620
|
if (this.__abort.signal.aborted)
|
|
580
|
-
return Promise.reject('Application already destroyed.');
|
|
621
|
+
return Promise.reject(new Error('Application already destroyed.'));
|
|
581
622
|
this.__abort.abort();
|
|
582
623
|
console.info("app destroy begin");
|
|
583
624
|
if (this.__execNav)
|
|
@@ -586,6 +627,8 @@ class Application extends ui.UIElement {
|
|
|
586
627
|
this.__lastNav.abort.abort();
|
|
587
628
|
if (this.__globalSubmit)
|
|
588
629
|
this.element?.removeEventListener("submit", this.__globalSubmit);
|
|
630
|
+
if (this.__onPopStateHandler)
|
|
631
|
+
window.removeEventListener("popstate", this.__onPopStateHandler);
|
|
589
632
|
const destroyAbort = new AbortController();
|
|
590
633
|
const context = {
|
|
591
634
|
abort: destroyAbort.signal,
|
|
@@ -621,6 +664,10 @@ class Application extends ui.UIElement {
|
|
|
621
664
|
const { form, button = null, query, data = {} } = opt;
|
|
622
665
|
if ((!button || !button.formNoValidate) && !form.checkValidity())
|
|
623
666
|
throw new Error('Form is invalid.');
|
|
667
|
+
// guard before applying any loading class, otherwise a rejected re-entrant
|
|
668
|
+
// submit would leave the button stuck in the loading state
|
|
669
|
+
if (form.classList.contains(result.LoadingElementClass))
|
|
670
|
+
throw new Error('Form already submitting.');
|
|
624
671
|
let replace = form.hasAttribute(result.NavUrlReplaceAttributeName);
|
|
625
672
|
let method = form.method;
|
|
626
673
|
let enctype = form.enctype;
|
|
@@ -636,62 +683,30 @@ class Application extends ui.UIElement {
|
|
|
636
683
|
if (button.hasAttribute(result.NavUrlReplaceAttributeName))
|
|
637
684
|
replace = true;
|
|
638
685
|
}
|
|
639
|
-
if (form.classList.contains(result.LoadingElementClass))
|
|
640
|
-
throw new Error('Form already submitting.');
|
|
641
686
|
form.classList.add(result.LoadingElementClass);
|
|
642
687
|
method = method.toUpperCase();
|
|
643
688
|
try {
|
|
644
|
-
if (method === "GET")
|
|
645
|
-
|
|
689
|
+
if (method === "GET") {
|
|
690
|
+
const getUrl = urlHelper.parseUrl(this.env.basePath, url);
|
|
691
|
+
urlHelper.extendQuery(getUrl, new FormData(form));
|
|
692
|
+
if (query)
|
|
693
|
+
urlHelper.extendQuery(getUrl, query);
|
|
694
|
+
await this.nav({ url: getUrl.url, data: data, replace, abort: opt.abort });
|
|
695
|
+
}
|
|
646
696
|
else {
|
|
647
697
|
const navUrl = urlHelper.parseUrl(this.env.basePath, url);
|
|
648
698
|
if (query)
|
|
649
699
|
urlHelper.extendQuery(navUrl, query);
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
parentNav.abort.abort(NAV_OVERIDE_ERROR);
|
|
654
|
-
parentNav.context.overided = true;
|
|
655
|
-
}
|
|
656
|
-
const navIndex = parentNav ? parentNav.context.index + 1 : 1;
|
|
657
|
-
const submitAbort = new AbortController();
|
|
658
|
-
const aborts = [this.__abort.signal, submitAbort.signal];
|
|
659
|
-
if (opt.abort)
|
|
660
|
-
aborts.push(opt.abort);
|
|
661
|
-
const complextAbort = AbortSignal.any(aborts);
|
|
662
|
-
let context = {
|
|
663
|
-
index: navIndex,
|
|
664
|
-
id: uiHelpers.Guid.createGuid(),
|
|
665
|
-
source: "submit",
|
|
666
|
-
app: this,
|
|
667
|
-
abort: complextAbort,
|
|
668
|
-
current: this.__lastNav?.context,
|
|
669
|
-
parent: parentNav?.context,
|
|
670
|
-
overided: false,
|
|
671
|
-
action: "submit",
|
|
672
|
-
data,
|
|
700
|
+
const base = this.__beginNav(opt.abort);
|
|
701
|
+
const context = {
|
|
702
|
+
...this.__createContext(base, navUrl, "submit", "submit", data, replace),
|
|
673
703
|
form,
|
|
674
704
|
button,
|
|
675
705
|
method,
|
|
676
|
-
enctype
|
|
677
|
-
url: navUrl.url,
|
|
678
|
-
origin: navUrl.origin,
|
|
679
|
-
pathAndQuery: navUrl.relative,
|
|
680
|
-
basePath: navUrl.basePath,
|
|
681
|
-
path: navUrl.path,
|
|
682
|
-
query: navUrl.query,
|
|
683
|
-
hash: navUrl.hash,
|
|
684
|
-
external: navUrl.external,
|
|
685
|
-
replace,
|
|
686
|
-
redirect: async (options) => {
|
|
687
|
-
complextAbort.throwIfAborted();
|
|
688
|
-
const result = await this.nav(options);
|
|
689
|
-
complextAbort.throwIfAborted();
|
|
690
|
-
return result;
|
|
691
|
-
}
|
|
706
|
+
enctype
|
|
692
707
|
};
|
|
693
|
-
const currentNav = { method: "submit", context, abort:
|
|
694
|
-
await this.__execNavigate(currentNav);
|
|
708
|
+
const currentNav = { method: "submit", context, abort: base.navAbort, status: "work" };
|
|
709
|
+
await this.__execNavigate(currentNav, base.parentNav);
|
|
695
710
|
}
|
|
696
711
|
}
|
|
697
712
|
finally {
|
|
@@ -703,7 +718,53 @@ class Application extends ui.UIElement {
|
|
|
703
718
|
__onPopState(_context, event) {
|
|
704
719
|
const popUrl = location.href;
|
|
705
720
|
console.log(`popstate: ${popUrl}`, event.state);
|
|
706
|
-
this.nav({ url: popUrl, data: { popstate: event.state } })
|
|
721
|
+
this.nav({ url: popUrl, data: { popstate: event.state } })
|
|
722
|
+
.catch(() => { });
|
|
723
|
+
}
|
|
724
|
+
/** Detect parent (overriding) navigation and compose the abort signal shared by a new navigation. */
|
|
725
|
+
__beginNav(abort) {
|
|
726
|
+
let parentNav;
|
|
727
|
+
if (this.__execNav && this.__execNav.status === "work") {
|
|
728
|
+
parentNav = this.__execNav;
|
|
729
|
+
parentNav.abort.abort(NAV_OVERIDE_ERROR);
|
|
730
|
+
parentNav.context.overided = true;
|
|
731
|
+
}
|
|
732
|
+
const navAbort = new AbortController();
|
|
733
|
+
const aborts = [this.__abort.signal, navAbort.signal];
|
|
734
|
+
if (abort)
|
|
735
|
+
aborts.push(abort);
|
|
736
|
+
return { parentNav, navAbort, abort: AbortSignal.any(aborts) };
|
|
737
|
+
}
|
|
738
|
+
/** Build the navigation context fields shared by nav and submit. */
|
|
739
|
+
__createContext(base, navUrl, source, action, data, replace) {
|
|
740
|
+
const { parentNav, abort } = base;
|
|
741
|
+
return {
|
|
742
|
+
index: parentNav ? parentNav.context.index + 1 : 1,
|
|
743
|
+
id: uiHelpers.Guid.createGuid(),
|
|
744
|
+
source,
|
|
745
|
+
app: this,
|
|
746
|
+
abort,
|
|
747
|
+
current: this.__lastNav?.context,
|
|
748
|
+
parent: parentNav?.context,
|
|
749
|
+
overided: false,
|
|
750
|
+
action,
|
|
751
|
+
data,
|
|
752
|
+
url: navUrl.url,
|
|
753
|
+
origin: navUrl.origin,
|
|
754
|
+
pathAndQuery: navUrl.relative,
|
|
755
|
+
basePath: navUrl.basePath,
|
|
756
|
+
path: navUrl.path,
|
|
757
|
+
query: navUrl.query,
|
|
758
|
+
hash: navUrl.hash,
|
|
759
|
+
external: navUrl.external,
|
|
760
|
+
replace,
|
|
761
|
+
redirect: async (options) => {
|
|
762
|
+
abort.throwIfAborted();
|
|
763
|
+
const result = await this.nav(options);
|
|
764
|
+
abort.throwIfAborted();
|
|
765
|
+
return result;
|
|
766
|
+
}
|
|
767
|
+
};
|
|
707
768
|
}
|
|
708
769
|
async __execNavigate(nav, parent) {
|
|
709
770
|
if (parent)
|
|
@@ -739,51 +800,76 @@ class Application extends ui.UIElement {
|
|
|
739
800
|
}
|
|
740
801
|
}
|
|
741
802
|
|
|
803
|
+
/** Fluent builder that configures the application type, middlewares and model, then constructs an {@link Application}. */
|
|
742
804
|
class ApplicationBuilder {
|
|
743
805
|
__model;
|
|
744
806
|
__appType = (Application);
|
|
745
807
|
__middlewares = [];
|
|
808
|
+
/**
|
|
809
|
+
* @param model Application model used when building the application.
|
|
810
|
+
*/
|
|
746
811
|
constructor(model) {
|
|
747
812
|
this.__model = model;
|
|
748
813
|
}
|
|
814
|
+
/**
|
|
815
|
+
* Set a custom application type to instantiate instead of the base {@link Application}.
|
|
816
|
+
* @param appType Application class constructor.
|
|
817
|
+
* @returns The builder, for chaining.
|
|
818
|
+
*/
|
|
749
819
|
useApp(appType) {
|
|
750
820
|
this.__appType = appType;
|
|
751
821
|
return this;
|
|
752
822
|
}
|
|
823
|
+
/**
|
|
824
|
+
* Register a middleware via its factory function. Middlewares run in registration order.
|
|
825
|
+
* @param createFunc Factory that creates the middleware instance.
|
|
826
|
+
* @param params Optional arguments passed to the factory.
|
|
827
|
+
* @returns The builder, for chaining.
|
|
828
|
+
*/
|
|
753
829
|
useMiddleware(createFunc, ...params) {
|
|
754
830
|
let midl = createFunc(...params);
|
|
755
831
|
this.__middlewares.push(midl);
|
|
756
832
|
return this;
|
|
757
833
|
}
|
|
834
|
+
/**
|
|
835
|
+
* Build and initialize the application instance.
|
|
836
|
+
* @param env Application environment (a base path of `/` is normalized to empty).
|
|
837
|
+
* @param args Extra arguments forwarded to the application constructor.
|
|
838
|
+
* @returns The initialized application instance.
|
|
839
|
+
*/
|
|
758
840
|
build(env, ...args) {
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
841
|
+
const appEnv = { ...env };
|
|
842
|
+
if (!appEnv.basePath || appEnv.basePath == '/')
|
|
843
|
+
appEnv.basePath = '';
|
|
844
|
+
const app = new this.__appType(appEnv, this.__model, ...args);
|
|
762
845
|
app.initialize(this.__middlewares);
|
|
763
846
|
return app;
|
|
764
847
|
}
|
|
765
848
|
}
|
|
766
849
|
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
850
|
+
// Guarded so importing the module does not throw in a non-DOM environment (SSR/Node).
|
|
851
|
+
if (typeof HTMLElement !== "undefined") {
|
|
852
|
+
HTMLElement.prototype.navUrl = function (url) {
|
|
853
|
+
if (this instanceof HTMLAnchorElement)
|
|
854
|
+
this.href = url;
|
|
855
|
+
else
|
|
856
|
+
this.dataset.navUrl = url;
|
|
857
|
+
this.classList.add(result.NavUrlClassName);
|
|
858
|
+
return this;
|
|
859
|
+
};
|
|
860
|
+
HTMLElement.prototype.nav = function (app, path, query, hash) {
|
|
861
|
+
const url = app.buildUrl(path, query, hash);
|
|
862
|
+
return this.navUrl(url);
|
|
863
|
+
};
|
|
864
|
+
HTMLElement.prototype.navReplace = function () {
|
|
865
|
+
this.setAttribute(result.NavUrlReplaceAttributeName, "");
|
|
866
|
+
return this;
|
|
867
|
+
};
|
|
868
|
+
HTMLElement.prototype.navScope = function (scope) {
|
|
869
|
+
this.setAttribute(result.NavUrlScopeAttributeName, scope);
|
|
870
|
+
return this;
|
|
871
|
+
};
|
|
872
|
+
}
|
|
787
873
|
|
|
788
874
|
exports.APP_TYPENAME = APP_TYPENAME;
|
|
789
875
|
exports.Application = Application;
|