UNPKG

ui-router

Version:

State-based routing for Javascript

251 lines (221 loc) 11 kB
/** @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}; } }