-
Notifications
You must be signed in to change notification settings - Fork 60
/
Copy pathview.ts
258 lines (228 loc) · 11.3 KB
/
view.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
/** @coreapi @module view */ /** for typedoc */
import {equals, applyPairs, removeFrom, TypedMap} from "../common/common";
import {curry, prop} from "../common/hof";
import {isString, isArray} from "../common/predicates";
import {trace} from "../common/trace";
import {PathNode} from "../path/node";
import {ActiveUIView, ViewContext, ViewConfig} from "./interface";
import {_ViewDeclaration} from "../state/interface";
export type ViewConfigFactory = (path: PathNode[], decl: _ViewDeclaration) => ViewConfig|ViewConfig[];
/**
* The View service
*/
export class ViewService {
private uiViews: ActiveUIView[] = [];
private viewConfigs: ViewConfig[] = [];
private _rootContext: ViewContext;
private _viewConfigFactories: { [key: string]: ViewConfigFactory } = {};
constructor() { }
rootContext(context?: ViewContext): ViewContext {
return this._rootContext = context || this._rootContext;
};
viewConfigFactory(viewType: string, factory: ViewConfigFactory) {
this._viewConfigFactories[viewType] = factory;
}
createViewConfig(path: PathNode[], decl: _ViewDeclaration): ViewConfig[] {
let cfgFactory = this._viewConfigFactories[decl.$type];
if (!cfgFactory) throw new Error("ViewService: No view config factory registered for type " + decl.$type);
let cfgs = cfgFactory(path, decl);
return isArray(cfgs) ? cfgs : [cfgs];
}
/**
* De-registers a ViewConfig.
*
* @param viewConfig The ViewConfig view to deregister.
*/
deactivateViewConfig(viewConfig: ViewConfig) {
trace.traceViewServiceEvent("<- Removing", viewConfig);
removeFrom(this.viewConfigs, viewConfig);
};
activateViewConfig(viewConfig: ViewConfig) {
trace.traceViewServiceEvent("-> Registering", <any> viewConfig);
this.viewConfigs.push(viewConfig);
};
sync = () => {
let uiViewsByFqn: TypedMap<ActiveUIView> =
this.uiViews.map(uiv => [uiv.fqn, uiv]).reduce(applyPairs, <any> {});
/**
* Given a ui-view and a ViewConfig, determines if they "match".
*
* A ui-view has a fully qualified name (fqn) and a context object. The fqn is built from its overall location in
* the DOM, describing its nesting relationship to any parent ui-view tags it is nested inside of.
*
* A ViewConfig has a target ui-view name and a context anchor. The ui-view name can be a simple name, or
* can be a segmented ui-view path, describing a portion of a ui-view fqn.
*
* In order for a ui-view to match ViewConfig, ui-view's $type must match the ViewConfig's $type
*
* If the ViewConfig's target ui-view name is a simple name (no dots), then a ui-view matches if:
* - the ui-view's name matches the ViewConfig's target name
* - the ui-view's context matches the ViewConfig's anchor
*
* If the ViewConfig's target ui-view name is a segmented name (with dots), then a ui-view matches if:
* - There exists a parent ui-view where:
* - the parent ui-view's name matches the first segment (index 0) of the ViewConfig's target name
* - the parent ui-view's context matches the ViewConfig's anchor
* - And the remaining segments (index 1..n) of the ViewConfig's target name match the tail of the ui-view's fqn
*
* Example:
*
* DOM:
* <div ui-view> <!-- created in the root context (name: "") -->
* <div ui-view="foo"> <!-- created in the context named: "A" -->
* <div ui-view> <!-- created in the context named: "A.B" -->
* <div ui-view="bar"> <!-- created in the context named: "A.B.C" -->
* </div>
* </div>
* </div>
* </div>
*
* uiViews: [
* { fqn: "$default", creationContext: { name: "" } },
* { fqn: "$default.foo", creationContext: { name: "A" } },
* { fqn: "$default.foo.$default", creationContext: { name: "A.B" } }
* { fqn: "$default.foo.$default.bar", creationContext: { name: "A.B.C" } }
* ]
*
* These four view configs all match the ui-view with the fqn: "$default.foo.$default.bar":
*
* - ViewConfig1: { uiViewName: "bar", uiViewContextAnchor: "A.B.C" }
* - ViewConfig2: { uiViewName: "$default.bar", uiViewContextAnchor: "A.B" }
* - ViewConfig3: { uiViewName: "foo.$default.bar", uiViewContextAnchor: "A" }
* - ViewConfig4: { uiViewName: "$default.foo.$default.bar", uiViewContextAnchor: "" }
*
* Using ViewConfig3 as an example, it matches the ui-view with fqn "$default.foo.$default.bar" because:
* - The ViewConfig's segmented target name is: [ "foo", "$default", "bar" ]
* - There exists a parent ui-view (which has fqn: "$default.foo") where:
* - the parent ui-view's name "foo" matches the first segment "foo" of the ViewConfig's target name
* - the parent ui-view's context "A" matches the ViewConfig's anchor context "A"
* - And the remaining segments [ "$default", "bar" ].join("."_ of the ViewConfig's target name match
* the tail of the ui-view's fqn "default.bar"
*/
const matches = (uiView: ActiveUIView) => (viewConfig: ViewConfig) => {
// Don't supply an ng1 ui-view with an ng2 ViewConfig, etc
if (uiView.$type !== viewConfig.viewDecl.$type) return false;
// Split names apart from both viewConfig and uiView into segments
let vc = viewConfig.viewDecl;
let vcSegments = vc.$uiViewName.split(".");
let uivSegments = uiView.fqn.split(".");
// Check if the tails of the segment arrays match. ex, these arrays' tails match:
// vc: ["foo", "bar"], uiv fqn: ["$default", "foo", "bar"]
if (!equals(vcSegments, uivSegments.slice(0 - vcSegments.length)))
return false;
// Now check if the fqn ending at the first segment of the viewConfig matches the context:
// ["$default", "foo"].join(".") == "$default.foo", does the ui-view $default.foo context match?
let negOffset = (1 - vcSegments.length) || undefined;
let fqnToFirstSegment = uivSegments.slice(0, negOffset).join(".");
let uiViewContext = uiViewsByFqn[fqnToFirstSegment].creationContext;
return vc.$uiViewContextAnchor === (uiViewContext && uiViewContext.name);
};
// Return the number of dots in the fully qualified name
function uiViewDepth(uiView: ActiveUIView) {
return uiView.fqn.split(".").length;
}
// Return the ViewConfig's context's depth in the context tree.
function viewConfigDepth(config: ViewConfig) {
let context: ViewContext = config.viewDecl.$context, count = 0;
while (++count && context.parent) context = context.parent;
return count;
}
// Given a depth function, returns a compare function which can return either ascending or descending order
const depthCompare = curry((depthFn, posNeg, left, right) => posNeg * (depthFn(left) - depthFn(right)));
const matchingConfigPair = (uiView: ActiveUIView) => {
let matchingConfigs = this.viewConfigs.filter(matches(uiView));
if (matchingConfigs.length > 1) {
// This is OK. Child states can target a ui-view that the parent state also targets (the child wins)
// Sort by depth and return the match from the deepest child
// console.log(`Multiple matching view configs for ${uiView.fqn}`, matchingConfigs);
matchingConfigs.sort(depthCompare(viewConfigDepth, -1)); // descending
}
return [uiView, matchingConfigs[0]];
};
const configureUIView = ([uiView, viewConfig]) => {
// If a parent ui-view is reconfigured, it could destroy child ui-views.
// Before configuring a child ui-view, make sure it's still in the active uiViews array.
if (this.uiViews.indexOf(uiView) !== -1)
uiView.configUpdated(viewConfig);
};
this.uiViews.sort(depthCompare(uiViewDepth, 1)).map(matchingConfigPair).forEach(configureUIView);
};
/**
* Allows a `ui-view` element to register its canonical name with a callback that allows it to
* be updated with a template, controller, and local variables.
*
* @param {String} name The fully-qualified name of the `ui-view` object being registered.
* @param {Function} configUpdatedCallback A callback that receives updates to the content & configuration
* of the view.
* @return {Function} Returns a de-registration function used when the view is destroyed.
*/
registerUIView(uiView: ActiveUIView) {
trace.traceViewServiceUIViewEvent("-> Registering", uiView);
let uiViews = this.uiViews;
const fqnMatches = uiv => uiv.fqn === uiView.fqn;
if (uiViews.filter(fqnMatches).length)
trace.traceViewServiceUIViewEvent("!!!! duplicate uiView named:", uiView);
uiViews.push(uiView);
this.sync();
return () => {
let idx = uiViews.indexOf(uiView);
if (idx === -1) {
trace.traceViewServiceUIViewEvent("Tried removing non-registered uiView", uiView);
return;
}
trace.traceViewServiceUIViewEvent("<- Deregistering", uiView);
removeFrom(uiViews)(uiView);
};
};
/**
* Returns the list of views currently available on the page, by fully-qualified name.
*
* @return {Array} Returns an array of fully-qualified view names.
*/
available() {
return this.uiViews.map(prop("fqn"));
}
/**
* Returns the list of views on the page containing loaded content.
*
* @return {Array} Returns an array of fully-qualified view names.
*/
active() {
return this.uiViews.filter(prop("$config")).map(prop("name"));
}
/**
* Normalizes a view's name from a state.views configuration block.
*
* @param context the context object (state declaration) that the view belongs to
* @param rawViewName the name of the view, as declared in the [[StateDeclaration.views]]
*
* @returns the normalized uiViewName and uiViewContextAnchor that the view targets
*/
static normalizeUIViewTarget(context: ViewContext, rawViewName = "") {
// TODO: Validate incoming view name with a regexp to allow:
// ex: "[email protected]" , "^.^.view.name" , "view.name@^.^" , "" ,
// "@" , "$default@^" , "!$default.$default" , "!foo.bar"
let viewAtContext: string[] = rawViewName.split("@");
let uiViewName = viewAtContext[0] || "$default"; // default to unnamed view
let uiViewContextAnchor = isString(viewAtContext[1]) ? viewAtContext[1] : "^"; // default to parent context
// Handle relative view-name sugar syntax.
// Matches rawViewName "^.^.^.foo.bar" into array: ["^.^.^.foo.bar", "^.^.^", "foo.bar"],
let relativeViewNameSugar = /^(\^(?:\.\^)*)\.(.*$)/.exec(uiViewName);
if (relativeViewNameSugar) {
// Clobbers existing contextAnchor (rawViewName validation will fix this)
uiViewContextAnchor = relativeViewNameSugar[1]; // set anchor to "^.^.^"
uiViewName = relativeViewNameSugar[2]; // set view-name to "foo.bar"
}
if (uiViewName.charAt(0) === '!') {
uiViewName = uiViewName.substr(1);
uiViewContextAnchor = ""; // target absolutely from root
}
// handle parent relative targeting "^.^.^"
let relativeMatch = /^(\^(?:\.\^)*)$/;
if (relativeMatch.exec(uiViewContextAnchor)) {
let anchor = uiViewContextAnchor.split(".").reduce(((anchor, x) => anchor.parent), context);
uiViewContextAnchor = anchor.name;
}
return {uiViewName, uiViewContextAnchor};
}
}