-
Notifications
You must be signed in to change notification settings - Fork 25
/
Copy pathModelManager.ts
661 lines (552 loc) · 21.6 KB
/
ModelManager.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
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import Constants from './Constants';
import { EditorClient, triggerPageModelLoaded } from './EditorClient';
import MetaProperty from './MetaProperty';
import { Model } from './Model';
import { ModelClient } from './ModelClient';
import { ModelStore } from './ModelStore';
import { PathUtils } from './PathUtils';
import { AuthoringUtils } from './AuthoringUtils';
import { initModelRouter, isRouteExcluded } from './ModelRouter';
/**
* Checks whether provided child path exists in the model.
* @param model Model to be evaluated.
* @param childPath Path of the child.
* @private
* @returns `true` if childPath exists in the model.
*/
function hasChildOfPath(model: any, childPath: string): boolean {
const sanited = PathUtils.sanitize(childPath);
if (!sanited) {
return false;
}
return !!(model && childPath && model[Constants.CHILDREN_PROP] && model[Constants.CHILDREN_PROP][sanited]);
}
/**
* Checks whether provided path corresponds to model root path.
* @param pagePath Page model path.
* @param modelRootPath Model root path.
* @returns `true` if provided page path is root
* @private
*/
function isPageURLRoot(pagePath: string, modelRootPath: string | undefined): boolean {
return !pagePath || !modelRootPath || (PathUtils.sanitize(pagePath) === PathUtils.sanitize(modelRootPath));
}
export interface ModelManagerConfiguration {
forceReload?: boolean;
model?: Model;
modelClient?: ModelClient;
path?: string;
errorPageRoot?: string;
}
interface ModelPaths {
rootModelURL?: string;
rootModelPath?: string;
currentPathname?: string | null;
metaPropertyModelURL?: string;
}
interface Page {
pagePath: string;
pageData?: Model;
}
/**
* @private
*/
export type ListenerFunction = () => void;
/**
* ModelManager is main entry point of this module.
*
* Example:
*
* Boostrap: `index.html`
* ```
* <head>
* <meta property="cq:pagemodel_root_url" content="{PATH}.model.json"/>
* </head>
* ```
*
* Bootstrap: `index.js`
* ```
* import { ModelManager } from '@adobe/aem-spa-page-model-manager';
*
* ModelManager.initialize().then((model) => {
* // Render the App content using the provided model
* render(model);
* });
*
* // Loading a specific portion of model
* ModelManager.getData("/content/site/page/jcr:content/path/to/component").then(...);
* ```
*
*
* For asynchronous loading of root model/standalone item model
* ```
* import { ModelManager } from '@adobe/aem-spa-page-model-manager';
*
* ModelManager.initializeAsync();
* ...
* // Render the App independent of the model
* render();
*
* ```
* For root model, custom event is fired on window with fetched model - cq-pagemodel-loaded
*/
export class ModelManager {
private _modelClient: ModelClient | undefined;
private _modelStore: ModelStore | undefined;
private _listenersMap: { [key: string]: ListenerFunction[] } = {};
private _fetchPromises: { [key: string]: Promise<Model> } = {};
private _initPromise: any;
private _editorClient: EditorClient | undefined;
private _clientlibUtil: AuthoringUtils | undefined;
private _modelPaths: ModelPaths = {};
private _errorPageRoot: string | undefined;
public get modelClient(): ModelClient {
if (!this._modelClient) {
throw new Error('ModelClient is undefined. Call initialize first!');
}
return this._modelClient;
}
public get modelStore(): ModelStore {
if (!this._modelStore) {
throw new Error('ModelStore is undefined. Call initialize first!');
}
return this._modelStore;
}
public get clientlibUtil(): AuthoringUtils {
if (!this._clientlibUtil) {
throw new Error('AuthoringUtils is undefined. Call initialize first!');
}
return this._clientlibUtil;
}
/**
* Initializes the ModelManager using the given path to resolve a data model.
* If no path is provided, fallbacks are applied in the following order:
* - meta property: `cq:pagemodel_root_url`
* - current path of the page
*
* If page model does not contain information about current path it performs additional fetch.
*
* @fires cq-pagemodel-loaded
* @return {Promise}
*/
public initialize<M extends Model>(config?: ModelManagerConfiguration | string): Promise<M> {
this.initializeAsync(config);
const { rootModelURL, rootModelPath } = this._modelPaths;
if (!rootModelURL) {
throw new Error('Provide root model url to initialize ModelManager.');
}
if (!rootModelPath) {
throw new Error('No root modelpath resolved! This should never happen.');
}
return this._initPromise;
}
/**
* Initializes the ModelManager asynchronously using the given path to resolve a data model.
* Ideal use case would be for remote apps which do not need the page model to be passed down from the root.
* For remote apps with no model path, an empty store is initialized and data is fetched on demand by components.
*
* Once the initial model is loaded and if the data model doesn't contain the path of the current pathname,
* the library attempts to fetch a fragment of model.
*
* Root model path is resolved in the following order of preference:
* - page path provided via config
* - meta property: `cq:pagemodel_root_url`
* - current path of the page for default SPA
* - if none, it defaults to empty string
*
* @fires cq-pagemodel-loaded if root model path is available
*/
public initializeAsync(config?: ModelManagerConfiguration | string): void {
this.destroy();
const modelConfig = this._toModelConfig(config);
const initialModel = modelConfig && modelConfig.model;
this._initializeFields(modelConfig);
this._initPromise = this._attachAEMLibraries();
const { rootModelPath } = this._modelPaths;
this._modelStore = new ModelStore(rootModelPath, initialModel);
if (rootModelPath) {
this._setInitializationPromise(rootModelPath);
}
initModelRouter();
}
/**
* Attaches detected in runtime required libraries to enable special AEM authoring capabilities.
*
* @private
*/
private _attachAEMLibraries() {
if (!PathUtils.isBrowser()) {
return Promise.resolve();
}
const docFragment = this.clientlibUtil.getAemLibraries();
if (!docFragment.hasChildNodes()) {
return Promise.resolve();
}
let outResolve: () => void;
const promise = new Promise(resolve => {
outResolve = resolve;
});
// @ts-ignore
this.clientlibUtil.setOnLoadCallback(docFragment, outResolve);
window.document.head.appendChild(docFragment);
return promise;
}
/**
* Initializes the class fields for ModelManager
*/
private _initializeFields(config?: ModelManagerConfiguration) {
this._listenersMap = {};
this._fetchPromises = {};
this._initPromise = null;
this._modelClient = ((config && config.modelClient) || new ModelClient());
this._errorPageRoot = config && config.errorPageRoot || undefined;
this._editorClient = new EditorClient(this);
this._clientlibUtil = new AuthoringUtils(this.modelClient.apiHost);
this._modelPaths = this._getPathsForModel(config);
}
/**
* Returns paths required for fetching root model
*/
private _getPathsForModel(config?: ModelManagerConfiguration) {
// Model path explicitly provided by user in config
const path = config?.path;
// Model path set statically via meta property
const pageModelRoot = PathUtils.getMetaPropertyValue(MetaProperty.PAGE_MODEL_ROOT_URL);
const metaPropertyModelURL = PathUtils.internalize(pageModelRoot);
const currentPathname = this._isRemoteApp() ? '' : PathUtils.getCurrentPathname();
// For remote apps in edit mode, to fetch path via parent URL
const sanitizedCurrentPathname = ((currentPathname && PathUtils.sanitize(currentPathname)) || '') as string;
// Fetch the app root model
// 1. consider the provided page path
// 2. consider the meta property value
// 3. fallback to the model path contained in the URL for the default SPA
const rootModelURL = path || metaPropertyModelURL || sanitizedCurrentPathname;
const rootModelPath = PathUtils.sanitize(rootModelURL) || '';
return {
currentPathname,
metaPropertyModelURL,
rootModelURL,
rootModelPath
};
}
/**
* Fetch page model from store and trigger cq-pagemodel-loaded event
* @returns Root page model
*/
private _fetchPageModelFromStore() {
const data = this.modelStore.getData();
triggerPageModelLoaded(data);
return data;
}
/**
* Sets initialization promise to fetch model if root path is available
* Also, to be returned on synchronous initialization
*/
private _setInitializationPromise(rootModelPath: string) {
const {
rootModelURL
} = this._modelPaths;
this._initPromise = (
this._initPromise.then(() => this._checkDependencies()).then(() => {
const data = this.modelStore.getData(rootModelPath);
if (data && (Object.keys(data).length > 0)) {
triggerPageModelLoaded(data);
return data;
} else if (rootModelURL) {
return this._fetchData(rootModelURL).then((rootModel: Model) => {
try {
this.modelStore.initialize(rootModelPath, rootModel);
// If currently active url model isn't available in the stored model, fetch and return it
// If already available, return the root page model from the store
return (
this._fetchActivePageModel(rootModel) || this._fetchPageModelFromStore()
);
} catch (e) {
console.error(`Error on initialization - ${e}`);
}
});
}
})
);
}
/**
* Fetch model for the currently active page
*/
private _fetchActivePageModel(rootModel: Model) {
const {
currentPathname,
metaPropertyModelURL
} = this._modelPaths;
const sanitizedCurrentPathname = ((currentPathname && PathUtils.sanitize(currentPathname)) || '') as string;
// Fetch and store model of currently active page
if (
!!currentPathname &&
!!sanitizedCurrentPathname && // active page path is available for fetching model
!isRouteExcluded(currentPathname) &&
!isPageURLRoot(currentPathname, metaPropertyModelURL) && // verify currently active URL is not same as the URL of the root model
!hasChildOfPath(rootModel, currentPathname) // verify fetched root model doesn't already contain the active path model
) {
return this._fetchData(currentPathname).then((model: Model) => {
this.modelStore.insertData(sanitizedCurrentPathname, model);
return this._fetchPageModelFromStore();
}).catch(e => {
console.warn('caught', e);
});
} else if (!!currentPathname && isRouteExcluded(currentPathname)) {
return this._fetchPageModelFromStore();
} else if (!PathUtils.isBrowser()) {
throw new Error(`Attempting to retrieve model data from a non-browser.
Please provide the initial data with the property key model`
);
}
}
/**
* Returns the path of the data model root.
* @returns Page model root path.
*/
public get rootPath(): string {
return this.modelStore.rootPath;
}
/**
* Returns the model for the given configuration.
* @param [config] Either the path of the data model or a configuration object. If no parameter is provided the complete model is returned.
* @returns Model object for specific path.
*/
public getData<M extends Model>(config?: ModelManagerConfiguration | string): Promise<M> {
let path = '';
let forceReload = false;
if (typeof config === 'string') {
path = config;
} else if (config) {
path = config.path || '';
forceReload = !!config.forceReload;
}
const initPromise = this._initPromise || Promise.resolve();
return initPromise.then(() => this._checkDependencies())
.then(() => {
if (!forceReload) {
const item = this.modelStore.getData(path);
if (item) {
return Promise.resolve(item);
}
}
// If data to be fetched for a component in a page not yet retrieved
// 1.Fetch the page data and store it
// 2.Return the required item data from the fetched page data
if (PathUtils.isItem(path)) {
const { pageData, pagePath } = this._getParentPage(path);
if (!pageData) {
return this._fetchData(pagePath).then((data: Model) => {
this._storeData(pagePath, data);
return this.modelStore.getData(path);
});
}
}
return this._fetchData(path).then((data: Model) => this._storeData(path, data));
});
}
/**
* Fetches the model for the given path.
* @param path Model path.
* @private
* @returns Model object for specific path.
*/
public _fetchData(path: string): Promise<Model> {
if (Object.prototype.hasOwnProperty.call(this._fetchPromises, path)) {
return this._fetchPromises[path];
}
if (this.modelClient) {
return new Promise<Model>((resolve, reject) => {
const promise = this.modelClient.fetch(this._toModelPath(path));
this._fetchPromises[path] = promise;
promise.then((obj) => {
delete this._fetchPromises[path];
if (this._isRemoteApp()) {
triggerPageModelLoaded(obj);
}
resolve(obj);
}).catch((error) => {
delete this._fetchPromises[path];
if (this._errorPageRoot !== undefined) {
const code = typeof error !== 'string' && error.response ? error.response.status : '500';
const errorPagePath = this._errorPageRoot + code + '.model.json';
if (path.indexOf(Constants.JCR_CONTENT) === -1 && path !== errorPagePath) {
this._fetchData(errorPagePath).then((response => {
response[Constants.PATH_PROP] = PathUtils.sanitize(path) || path;
resolve(response);
})).catch(reject);
} else {
reject(error);
}
} else {
reject(error);
}
});
});
} else {
throw new Error('ModelClient not initialized!');
}
}
/**
* Notifies the listeners for a given path.
* @param path Path of the data model.
* @private
*/
public _notifyListeners(path: string): void {
path = PathUtils.adaptPagePath.call(this, path);
if (!this._listenersMap) {
throw new Error('ListenersMap is undefined.');
}
const listenersForPath: ListenerFunction[] = this._listenersMap[path];
if (!listenersForPath) {
return;
}
if (listenersForPath.length) {
listenersForPath.forEach((listener: ListenerFunction) => {
try {
listener();
} catch (e) {
console.error(`Error in listener ${listenersForPath} at path ${path}: ${e}`);
}
});
}
}
/**
* Add the given callback as a listener for changes at the given path.
* @param path Absolute path of the resource (e.g., "/content/mypage"). If not provided, the root page path is used.
* @param callback Function to be executed listening to changes at given path.
*/
public addListener(path: string, callback: ListenerFunction): void {
if (!path || (typeof path !== 'string') || (typeof callback !== 'function')) {
return;
}
const adaptedPath = PathUtils.adaptPagePath(path, this.modelStore?.rootPath);
this._listenersMap[adaptedPath] = this._listenersMap[path] || [];
this._listenersMap[adaptedPath].push(callback);
}
/**
* Remove the callback listener from the given path path.
* @param path Absolute path of the resource (e.g., "/content/mypage"). If not provided, the root page path is used.
* @param callback Listener function to be removed.
*/
public removeListener(path: string, callback: ListenerFunction): void {
if (!path || (typeof path !== 'string') || (typeof callback !== 'function')) {
return;
}
const adaptedPath = PathUtils.adaptPagePath(path, this.modelStore?.rootPath);
const listenersForPath = this._listenersMap[adaptedPath];
if (listenersForPath) {
const index = listenersForPath.indexOf(callback);
if (index !== -1) {
listenersForPath.splice(index, 1);
}
}
}
/**
* @private
*/
private destroy() {
if (this._modelClient && this._modelClient.destroy) {
this._modelClient.destroy();
}
if (this._modelStore && this._modelStore.destroy) {
this._modelStore.destroy();
}
if (this._editorClient && this._editorClient.destroy) {
this._editorClient.destroy();
}
}
private _storeData(path: string, data: Model) {
let isItem = false;
if (this._modelStore) {
isItem = PathUtils.isItem(path);
}
if (data && (Object.keys(data).length > 0)) {
this.modelStore.insertData(path, data);
// If the path correspond to an item notify either the parent item
// Otherwise notify the app root
this._notifyListeners(path);
}
if (!isItem) {
// As we are expecting a page, we notify the root
this._notifyListeners('');
}
return data;
}
/**
* Transforms the given path into a model URL.
* @private
* @return {*}
*/
private _toModelPath(path: string) {
let url = PathUtils.addSelector(path, 'model');
url = PathUtils.addExtension(url, 'json');
url = PathUtils.externalize(url);
return PathUtils.makeAbsolute(url);
}
/**
* Transforms the given config into a ModelManagerConfiguration object
* Removes redundant string or object check for path
* @return {object}
* @private
*/
private _toModelConfig(config?: ModelManagerConfiguration | string): ModelManagerConfiguration {
if (!config || typeof config !== 'string') {
return ((config || {}) as ModelManagerConfiguration);
}
return {
path: config
};
}
/**
* Verifies the integrity of the provided dependencies
*
* @return {Promise}
* @private
*/
private _checkDependencies() {
if (!this.modelClient) {
return Promise.reject('No ModelClient registered.');
}
if (!this.modelStore) {
return Promise.reject('No ModelManager registered.');
}
return Promise.resolve();
}
/**
* Fetches parent page information of the given component path
* Returns object containing
* 1. Parent page path
* 2. Parent page data if already available in the store
* @return {object}
*/
private _getParentPage(path: string): Page {
const dataPaths = PathUtils.splitPageContentPaths(path);
const pagePath = dataPaths?.pagePath || '';
const pageData = this.modelStore.getData(pagePath);
return {
pageData,
pagePath
};
}
/**
* Checks if the currently open app in aem editor is a remote app
* @returns true if remote app
*/
public _isRemoteApp(): boolean {
const aemApiHost = this.modelClient.apiHost || '';
return (PathUtils.isBrowser() && aemApiHost.length > 0 && (PathUtils.getCurrentURL() !== aemApiHost));
}
}
export default new ModelManager();