@akc42/app-utils 3.6.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -152,3 +152,102 @@ In doing this it can traverse inside custom elements looking for input elements.
152
152
  # switch-path
153
153
 
154
154
  Changed window location based on parameters provided
155
+
156
+ # distributed-router
157
+ Distributed Client Side Router for use with a hierarchy of custom components in an Single Page Application
158
+
159
+ This is a series of javascript modules that you can link together to form a distributed client router system. It links
160
+ the browsers URL bar into a chained list of `Routee` objects that process a part of the segmented url. It takes in a `route` object
161
+ and passes out a `subRoute` object. These are chained together, the subRoute at one level being fed into route of the next level down
162
+ the hierarchy.
163
+
164
+ At the top level is a pair of functions `connectUrl` and `disconnectUrl`. Which ever element will be your master controller (I generally
165
+ have a `<main-app>` element which has a `<session-manager>` and `<page-manager>`, both of which are controlling pages. A `<session-manager>`
166
+ page doesn't reflect in the url bar at all as the user navigates the sign in process. But once authorised, the `<page-manager>` takes over
167
+ and controls which page is displayed based on the url. So it is the `<page-manager>` custom element that calls `connectUrl` in its `connectedCallback`
168
+ function, and `disconnectUrl` in its `disconnectedCallback` function. `connectUrl` uses a callback function as its only parameter and this callback
169
+ function gets called on url change, passing the top level `route` object.
170
+
171
+ The next piece in this arrangement is a router. This is a class called `Route` and is instanciated with one required parameter and one
172
+ optional parameter. The required parameter is a string containing "/" separated segments, which must either literally match the part of
173
+ the url, or can start with a ":" character followed by a name, in which case we assume that that part of the url should be interpreted
174
+ as a parameter. We process a new `route` (however we receive it - either via the `connectUrl` callback, or being passed into a custom
175
+ element via a property/attribute) by calling the `routeChange` method, this returns a `route` object which the part of the url segment
176
+ checked against the specification provdied in the `new Route()` call. `route` has an `active` property to determine if it matched and
177
+ a `params` property the value of any of the ":" segments. Any queryString is also decoded and placed in the `query` property of objects.
178
+
179
+ If the `active` propery of a route is false, the subRoute will also have an `active` value of false. A `query` property is always passed
180
+ straight through and it is up to the application to decided how and when to use it.
181
+
182
+ The optional second parameter to the `new Route()` call is a matching string for the previous route up the chain. It consists of a string
183
+ which contains a single ":" character. The left of the ":" character is a parameter name, and to the right a parameter value. The incoming
184
+ route's `params` property must contain the "name" and it must have the value "value" for the subRoute to be active (as well as matching the url).
185
+
186
+ This is usually used with something like this
187
+ ```
188
+ const topLevel = new Route('/:page');
189
+ const firstLevel = new Route('/:id', 'page:appointments');
190
+ connectUrl(route => {
191
+ const subRoute = topLevel.routeChange(route);
192
+ if (subRoute.active) {
193
+ ...
194
+
195
+ const subSubRoute = firstLevel.routeChange(subRoute);
196
+ if (subSubRoute.active) {
197
+ readDatabaseRecord(subSubRoute.params.id)
198
+ }
199
+ ...
200
+ }
201
+ });
202
+
203
+ ```
204
+ (I have simplified what happens - subRoute would probably be passed in as the `route` property to a custom element which might at some point want
205
+ read a database record based on id).
206
+
207
+ In this example we only want to read the (lets say) the appointment record from the database if the `<appointment-manager>` element had been activated
208
+ with a url of the form "/appointements/53" and not (say) when the url was "/user/53", when the `<user-manager>` element is in the dom and the `<appointment-manager>` is still in the dom, but not doing anything. The other obvious question is why not do this:-
209
+
210
+ ```
211
+ const firstLevel = new Route('/appointments/:id');
212
+ connectUrl(route => {
213
+ const subRoute = topLevel.routeChange(route);
214
+ if (subRoute.active) {
215
+ ...
216
+
217
+ const subSubRoute = firstLevel.routeChange(subRoute);
218
+ if (subSubRoute.active) {
219
+ readDatabaseRecord(subSubRoute.params.id)
220
+ }
221
+ ...
222
+ }
223
+ });
224
+
225
+ ```
226
+ and the answer to that is that I have an element `<route-manager>` which in fact something like `<page-manager>` extends
227
+ which then allows me to do (in `lit-element`s `render` function)
228
+ ```
229
+ ${ {
230
+ home: html`<app-home></app-home>`,
231
+ user: html`<app-user managed-page .route=${this.subRoute}></app-user>
232
+ appointments: html`<app-appointments managed-page .route=${this.subRoute}></app-appointments`
233
+ }[this.page]
234
+ }
235
+ ```
236
+
237
+ The route manager users `new Route('/:page')` to translate the incoming `route` to the `page` property.
238
+
239
+ Internally the `Route` class uses a `route-changed` event which this overall module listens to on the window and this can be used to change the url.
240
+ the `Route` class has three properties that can be set and which can change the url.
241
+
242
+ - `connection` which if set `true` join the input and output of the route managed by this instance provided only that the route doesn't have any ":" segment,
243
+ and change the url accordingly. If set to `false` it will always make the output disconnected.
244
+ - `params` which when set with an object which maps the properties of an active `params` in the `subRoute` will change the url - so for instance in the example
245
+ above calling `firstlevel.params = {id: 20}` will change the url to `/appointments/20`.
246
+ - `query` we can set a query set of parameters and these will then change the url to have those query parameters.
247
+
248
+ Other modules that wish to change the url can do so, but they need to dispatch a `location-altered` event in the window. A helper
249
+ class `LocationAltered` can generate it for you, so to change the location do:-
250
+ ```
251
+ history.pushState({}, null, '/user/23');
252
+ window.dispatchEvent(new LocationAltered());
253
+ ```
package/location.js ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ @licence
3
+ Copyright (c) 2020 Alan Chandler, all rights reserved
4
+
5
+ This file is part of Distributed Router.
6
+
7
+ Distributed Router is free software: you can redistribute it and/or modify
8
+ it under the terms of the GNU General Public License as published by
9
+ the Free Software Foundation, either version 3 of the License, or
10
+ (at your option) any later version.
11
+
12
+ Distributed Router is distributed in the hope that it will be useful,
13
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ GNU General Public License for more details.
16
+
17
+ You should have received a copy of the GNU General Public License
18
+ along with Distributed Router. If not, see <http://www.gnu.org/licenses/>.
19
+ */
20
+
21
+ import {Debug} from './debug.js';
22
+ let routeCallback = null;
23
+ let lastChangedAt;
24
+ let route = null;
25
+
26
+ const debug = Debug('router');
27
+
28
+ const dwellTime = () => {
29
+ return parseInt(localStorage.getItem('dwellTime') || 2000, 10); //dwell time might not be set initially, so keep retrying.
30
+ }
31
+
32
+ export function connectUrl(callback) {
33
+ debug('connectUrl called')
34
+ if (routeCallback === null) {
35
+ window.addEventListener('hashchange', urlChanged);
36
+ window.addEventListener('popstate', urlChanged);
37
+ window.addEventListener('location-altered', urlChanged);
38
+ window.addEventListener('route-changed', routeChanged);
39
+ }
40
+ routeCallback = callback;
41
+ route = null;
42
+ Promise.resolve().then(() => {
43
+ urlChanged();
44
+ lastChangedAt = window.performance.now() - (dwellTime() - 200); //first time we need to adjust for dwell time
45
+ });
46
+
47
+ }
48
+ export function disconnectUrl() {
49
+ debug('disconnectUrl called')
50
+ routeCallback = null;
51
+ window.removeEventListener('hashchange',urlChanged);
52
+ window.removeEventListener('popstate', urlChanged);
53
+ window.removeEventListener('location-altered',urlChanged);
54
+ window.removeEventListener('route-changed', routeChanged);
55
+ }
56
+
57
+ function urlChanged() {
58
+ let path = window.decodeURIComponent(window.location.pathname);
59
+ const slashIndex = path.lastIndexOf('/');
60
+ if (path.substring(slashIndex + 1).indexOf('.') >= 0) {
61
+ //we have a '.' in the last part of the path, so cut off this segment
62
+ path = slashIndex < 0 ? '/' : path.substring(0,slashIndex);
63
+ }
64
+ const query = decodeParams(window.location.search.substring(1));
65
+ if (route && route.path === path && JSON.stringify(route.query) === JSON.stringify(query)) return;
66
+ debug('url change route to path',path, 'has query', Object.keys(query).length > 0 )
67
+ lastChangedAt = window.performance.now();
68
+ route = {
69
+ path: path ,
70
+ segment: 0,
71
+ params: {},
72
+ query: query,
73
+ active: true
74
+ };
75
+
76
+ if (routeCallback) routeCallback(route);
77
+ }
78
+ function routeChanged(e) {
79
+ let newPath = route.path;
80
+ if(e.detail.path !== undefined) {
81
+ if (Number.isInteger(e.detail.segment)) {
82
+ debug('route change called path', e.detail.path, 'segments', d.detail.segment, 'current path', route.path )
83
+ let segments = route.path.split('/');
84
+ if (segments[0] === '') segments.shift(); //loose leeding
85
+ if(segments.length < e.detail.segment) {
86
+ throw new Error('routeUpdated with a segment longer than current route');
87
+ }
88
+ if(segments.length > e.detail.segment) segments.length = e.detail.segment; //truncate to just before path
89
+ if (e.detail.path.length > 1) {
90
+ const newPaths = e.detail.path.split('/');
91
+ if (newPaths[0] === '') newPaths.shift(); //ignore blank if first char of path is '/'
92
+ segments = segments.concat(newPaths);
93
+ }
94
+ newPath = '/' + segments.join('/');
95
+ //lose trailing slash if not just a single '/'
96
+ if (newPath.slice(-1) === '/' && newPath.length > 1) newPath = newPath.slice(0,-1);
97
+ } else {
98
+ throw new Error('Invalid segment info in route-updated event');
99
+ }
100
+ }
101
+ let query = Object.assign({}, route.query);
102
+ if (e.detail.query !== undefined) {
103
+ query = e.detail.query;
104
+ }
105
+ let newUrl = window.encodeURI(newPath).replace(/#/g, '%23').replace(/\?/g, '%3F');
106
+ if (Object.keys(query).length > 0) {
107
+ newUrl += '?' + encodeParams(query)
108
+ .replace(/%3F/g, '?')
109
+ .replace(/%2F/g, '/')
110
+ .replace(/'/g, '%27')
111
+ .replace(/#/g, '%23')
112
+ ;
113
+
114
+ }
115
+ newUrl += window.location.hash;
116
+ // Tidy up if base tag in header
117
+ const fullUrl = new URL(newUrl, window.location.protocol + '//' + window.location.host).href;
118
+ if (fullUrl !== window.location.href) { //has it changed?
119
+ let now = window.performance.now();
120
+ if (lastChangedAt + dwellTime() > now) {
121
+ window.history.replaceState({}, '', fullUrl);
122
+ } else {
123
+ window.history.pushState({}, '', fullUrl);
124
+ }
125
+ urlChanged();
126
+ }
127
+ }
128
+ function encodeParams(params) {
129
+ const encodedParams = [];
130
+
131
+ for (let key in params) {
132
+ const value = params[key];
133
+ if (value === '') {
134
+ encodedParams.push(encodeURIComponent(key) + '=');
135
+ } else {
136
+ encodedParams.push(
137
+ encodeURIComponent(key) + '=' +
138
+ encodeURIComponent(value.toString()));
139
+ }
140
+ }
141
+ return encodedParams.join('&');
142
+ }
143
+ function decodeParams(paramString) {
144
+ var params = {};
145
+ // Work around a bug in decodeURIComponent where + is not
146
+ // converted to spaces:
147
+ paramString = (paramString || '').replace(/\+/g, '%20');
148
+ var paramList = paramString.split('&');
149
+ for (var i = 0; i < paramList.length; i++) {
150
+ var param = paramList[i].split('=');
151
+ if (param.length === 2) {
152
+ let value;
153
+ try {
154
+ value = decodeURIComponent(param[1]);
155
+ if (value === 'true') {
156
+ value = true;
157
+ } else if (value === 'false') {
158
+ value = false;
159
+ } else if (/^-?(0|[1-9]\d*)$/.test(value)) {
160
+ //convert to integer, only if not a value with leading zero (like phone number)
161
+ value = parseInt(value,10);
162
+ }
163
+ } catch (e) {
164
+ value = '';
165
+ }
166
+ params[decodeURIComponent(param[0])] = value;
167
+ }
168
+ }
169
+ return params;
170
+ }
171
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akc42/app-utils",
3
- "version": "3.6.2",
3
+ "version": "4.0.0",
4
4
  "description": "General Utilities for SPAs",
5
5
  "exports": {
6
6
  ".": "./*.js"
package/post-api.js CHANGED
@@ -52,6 +52,7 @@ export default async function api(url, params, blob, signal) {
52
52
  }
53
53
  } catch (err) {
54
54
  if (!options.signal || !options.signal.aborted) {
55
+ if (err instanceof TypeError) throw new CustomEvent('api-network', { bubbles:true, composed:true, detail: address})
55
56
  if (err.type === 'api-error') throw err; //just throw whatever error we had
56
57
  //we failed to parse the json - the actual code should be in the text near the end;
57
58
  throw new CustomEvent('api-error', { composed: true, bubbles: true, detail: {status:parseInt((text?? '---502---').slice(-6, -3), 10), url:address }});
package/route.js ADDED
@@ -0,0 +1,233 @@
1
+ /**
2
+ @licence
3
+ Copyright (c) 2020 Alan Chandler, all rights reserved
4
+
5
+ This file is part of Distributed Router.
6
+
7
+ Distributed Router is free software: you can redistribute it and/or modify
8
+ it under the terms of the GNU General Public License as published by
9
+ the Free Software Foundation, either version 3 of the License, or
10
+ (at your option) any later version.
11
+
12
+ Distributed Router is distributed in the hope that it will be useful,
13
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
14
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
+ GNU General Public License for more details.
16
+
17
+ You should have received a copy of the GNU General Public License
18
+ along with Distributed Router. If not, see <http://www.gnu.org/licenses/>.
19
+ */
20
+ import {Debug} from './debug.js';
21
+
22
+ const debug = Debug('router');
23
+
24
+ export default class Route {
25
+ constructor(match = '', ifmatched = '') {
26
+ //set default values
27
+ this.preroute = {active: false, segment: 0, path: '', params: {}, query: {}};
28
+ if (ifmatched.length > 0) {
29
+ this.matcher = ifmatched.split(':');
30
+ if (this.matcher.length !== 2) throw new Error('pas-route: Invalid ifmatched String');
31
+ } else {
32
+ this.matcher = [];
33
+ }
34
+ this.match = match;
35
+ //this is our output
36
+ this._route = {active: false, segment: 0, path: '', params: {}, query: {}};
37
+ this.sentRouteChanged = false;
38
+ debug('new route made with match', match, 'ifmatched', ifmatched)
39
+ }
40
+
41
+ routeChange(preroute) {
42
+ debug('routeChanged called with preroute of', JSON.stringify(preroute));
43
+ this.preroute = preroute; //remember it
44
+ if (preroute !== undefined && preroute.active && preroute.path.length > 0 &&
45
+ this._ifMatches(preroute.params) ) {
46
+ if (this.match.length > 0) {
47
+ let completed = false;
48
+ if (this.match.slice(-1) === '/') {
49
+ completed = true; //Special meaning
50
+ if (this.match.length > 1) {
51
+ this.match = this.match.slice(0,-1);
52
+ }
53
+ }
54
+ const matchedPieces = this.match.split('/');
55
+ if (matchedPieces[0] === '') matchedPieces.shift(); //not interested in blank front
56
+
57
+ const urlPieces = preroute.path.split('/');
58
+ if (urlPieces.length < 2 || urlPieces[0] !== '') {
59
+ //something is wrong with path as it should have started with a '/'
60
+ this._route.active = false;
61
+ throw new Error('Route: Invalid path (should start with /) in route');
62
+ }
63
+
64
+ urlPieces.shift();
65
+ let j = urlPieces.length;
66
+ const newRoute = {
67
+ segment: preroute.segment + matchedPieces.length,
68
+ params: {},
69
+ active: true,
70
+ query: preroute.query
71
+ };
72
+ for(let i = 0; i < matchedPieces.length; i++) {
73
+ if (j <= 0) {
74
+ return this._clearOutActive();
75
+ }
76
+ let segment = urlPieces.shift();
77
+ j--;
78
+ if (matchedPieces[i].length !== 0) {
79
+ if (matchedPieces[i].substring(0,1) === ':') {
80
+ const key = matchedPieces[i].substring(1);
81
+ if (key.length > 0) {
82
+ if (/^-?\d+$/.test(segment)) {
83
+ segment = parseInt(segment,10);
84
+ }
85
+ newRoute.params[key] = segment;
86
+ } else {
87
+ throw new Error('Route: Match pattern missing parameter name');
88
+ }
89
+ } else if (matchedPieces[i] !== segment) {
90
+ return this._clearOutActive();
91
+ }
92
+ } else if (segment.length > 0 ){
93
+ return this._clearOutActive();
94
+ }
95
+ }
96
+ if (completed || preroute.path === '/') {
97
+ newRoute.path = '';
98
+ } else if (j == 0) {
99
+ newRoute.path = '/';
100
+ } else {
101
+ newRoute.path = '/' + urlPieces.join('/');
102
+ }
103
+ if (!this._route.active ||
104
+ JSON.stringify(this._route.params) !== JSON.stringify(newRoute.params) ||
105
+ JSON.stringify(this._route.query) !== JSON.stringify(newRoute.query) ||
106
+ this._route.path !== newRoute.path || this._route.segment !== newRoute.segment) {
107
+ this._route = newRoute;
108
+ this.sentRouteChanged = true;
109
+ }
110
+ debug('Route Change returning (no clear active)', JSON.stringify(this._route));
111
+ } else {
112
+ throw new Error('Route: Match String Required');
113
+ }
114
+ } else {
115
+ this._clearOutActive();
116
+ }
117
+ return this._route;
118
+ }
119
+ /*
120
+ * set new paramters provided route is active
121
+ */
122
+ set params(value) {
123
+ if (this._route.active) {
124
+ let match = this.match;
125
+ if (match.slice(-1) === '/' && match.length > 1) match = this.match.slice(0,-1);
126
+ const matchedPieces = match.split('/');
127
+ if (matchedPieces[0] === '') matchedPieces.shift(); //not interested in blank front
128
+
129
+ let urlPieces = this.preroute.path.split('/');
130
+ urlPieces.shift(); //loose blank front
131
+ let changeMade = false;
132
+ for (let i = 0; i < matchedPieces.length; i++) {
133
+ if (urlPieces.length < i) urlPieces.push(''); //ensure there is a url segment for this match
134
+ if (matchedPieces[i].length !== 0) {
135
+ if (matchedPieces[i].substring(0,1) === ':') {
136
+ const key = matchedPieces[i].substring(1);
137
+ if (value[key] !== undefined) {
138
+ if (Number.isInteger(value[key])) {
139
+ if (urlPieces[i] !== value[key].toString()) {
140
+ urlPieces[i] = value[key].toString();
141
+ changeMade = true;
142
+ }
143
+ } else if (typeof value[key] === 'string') {
144
+ if (value[key].length > 0) {
145
+ if (urlPieces[i] !== value[key]) {
146
+ urlPieces[i] = value[key];
147
+ changeMade = true;
148
+ }
149
+ } else {
150
+ //terminate url here
151
+ urlPieces.length = i;
152
+ changeMade = true;
153
+ break;
154
+ }
155
+ } else if (value[key] === null) {
156
+ //terminate url here
157
+ urlPieces.length = i;
158
+ changeMade = true;
159
+ break;
160
+ } else {
161
+ throw new Error('Route: Invalid params.' + key + ' provided (should be a String or an Integer)');
162
+ }
163
+ }
164
+ }
165
+ }
166
+ }
167
+ if (changeMade) {
168
+ const path = '/' + urlPieces.join('/');
169
+ debug('params set, about to set path',path,'segment', this.preroute.segment );
170
+ window.dispatchEvent(new CustomEvent ('route-changed',{bubbles: true, composed: true, detail:{
171
+ segment: this.preroute.segment,
172
+ path: path
173
+ }}));
174
+ }
175
+ }
176
+ }
177
+ /*
178
+ * Set a new query value provided route is active
179
+ */
180
+ set query(value) {
181
+ const jsv = JSON.stringify(value)
182
+ debug('set query to value', jsv)
183
+ if (this._route.active && JSON.stringify(this._route.query) !== jsv) {
184
+ window.dispatchEvent(new CustomEvent('route-changed',{bubbles: true, composed: true, detail:{query: value}}));
185
+ }
186
+ }
187
+ /*
188
+ * We can set or break the connection between a pre-route and its route
189
+ */
190
+ set connection(value) {
191
+ debug('set connection called with value', value);
192
+ if (this.preroute.active) {
193
+ if (this._route.active) {
194
+ if (value) return; //can't set a matched route active
195
+ //just reset to a url
196
+ window.dispatchEvent(new CustomEvent('route-changed', { bubbles: true, composed: true, detail:{
197
+ segment: this.preroute.segment,
198
+ path: '/'
199
+ }}));
200
+ } else {
201
+ if (value) {
202
+ let match = this.match;
203
+ if (match.slice(-1) === '/' && match.length > 1) match = this.match.slice(0,-1);
204
+ const matchedPieces = match.split('/');
205
+ if (matchedPieces[0] === '') matchedPieces.shift(); //not interested in blank front
206
+ if (matchedPieces.length < 1) return;
207
+ if (matchedPieces.every(piece => piece.length > 0 && piece.indexOf(':') < 0)) {
208
+ window.dispatchEvent(new CustomEvent('route-changed', {bubbles: true, composed: true, detail:{
209
+ segment: this.preroute.segment,
210
+ path: '/' + matchedPieces.join('/')
211
+ }}));
212
+
213
+ }
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ _ifMatches (params) {
220
+ if (this.matcher.length === 0) return true; //Empty String always matches
221
+ return (params[this.matcher[0]] !== undefined && params[this.matcher[0]] === this.matcher[1]);
222
+ }
223
+ _clearOutActive () {
224
+ if (this._route === undefined) return;
225
+ if (this._route.active || !this.sentRouteChanged) {
226
+ this._route = Object.assign({}, this._route, {active:false});
227
+ this.sentRouteChanged = true;
228
+ }
229
+ debug('clearOutActive returning route', JSON.stringify(this._route));
230
+ return this._route;
231
+ }
232
+ }
233
+