ui-router
Version:
State-based routing for Javascript
251 lines (221 loc) • 11 kB
text/typescript
/** @module view */ /** for typedoc */
import {equals, applyPairs, removeFrom, TypedMap} from "../common/common";
import {curry, prop} from "../common/hof";
import {isString} from "../common/predicates";
import {trace} from "../common/module";
import {Node} from "../path/node";
import {ActiveUIView, ViewContext, ViewConfig} from "./interface";
import {_ViewDeclaration} from "../state/interface";
const match = (obj1, ...keys) =>
(obj2) => keys.reduce((memo, key) => memo && obj1[key] === obj2[key], true);
export type ViewConfigFactory = (node: Node, decl: _ViewDeclaration) => ViewConfig;
/**
* The View service
*/
export class ViewService {
private uiViews: ActiveUIView[] = [];
private viewConfigs: ViewConfig[] = [];
private _rootContext;
private _viewConfigFactories: { [key: string]: ViewConfigFactory } = {};
constructor() { }
rootContext(context) {
return this._rootContext = context || this._rootContext;
};
viewConfigFactory(viewType: string, factory: ViewConfigFactory) {
this._viewConfigFactories[viewType] = factory;
}
createViewConfig(node: Node, decl: _ViewDeclaration): ViewConfig {
let cfgFactory = this._viewConfigFactories[decl.$type];
if (!cfgFactory) throw new Error("ViewService: No view config factory registered for type " + decl.$type);
return cfgFactory(node, decl);
}
/**
* 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.
*
* 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) => {
// 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 => {
let matchingConfigs = this.viewConfigs.filter(matches(uiView));
if (matchingConfigs.length > 1)
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 <= 0) {
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: "view.name@foo.bar" , "^.^.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};
}
}