Skip to content

Commit b3bd2fe

Browse files
refactor(HookBuilder): support entering/exiting/retained matchcriteria
1 parent dcbaebf commit b3bd2fe

File tree

8 files changed

+439
-126
lines changed

8 files changed

+439
-126
lines changed

src/state/hooks/enterExitHooks.ts

+6-6
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,17 @@ export class EnterExitHooks {
1515
}
1616

1717
registerOnEnterHooks() {
18-
let onEnterRegistration = (state) => this.transition.onEnter({to: state.name}, state.onEnter);
19-
this.transition.entering().filter(state => !!state.onEnter).forEach(onEnterRegistration);
18+
this.transition.entering().filter(state => !!state.onEnter)
19+
.forEach(state => this.transition.onEnter({entering: state.name}, state.onEnter));
2020
}
2121

2222
registerOnRetainHooks() {
23-
let onRetainRegistration = (state) => this.transition.onRetain({}, state.onRetain);
24-
this.transition.retained().filter(state => !!state.onRetain).forEach(onRetainRegistration);
23+
this.transition.retained().filter(state => !!state.onRetain)
24+
.forEach(state => this.transition.onRetain({retained: state.name}, state.onRetain));
2525
}
2626

2727
registerOnExitHooks() {
28-
let onExitRegistration = (state) => this.transition.onExit({from: state.name}, state.onExit);
29-
this.transition.exiting().filter(state => !!state.onExit).forEach(onExitRegistration);
28+
this.transition.exiting().filter(state => !!state.onExit)
29+
.forEach(state => this.transition.onExit({exiting: state.name}, state.onExit));
3030
}
3131
}

src/state/hooks/resolveHooks.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {propEq} from "../../common/hof";
55
import {ResolvePolicy} from "../../resolve/interface";
66

77
import {Transition} from "../../transition/transition";
8+
import {val} from "../../common/hof";
89

910

1011
let LAZY = ResolvePolicy[ResolvePolicy.LAZY];
@@ -38,6 +39,6 @@ export class ResolveHooks {
3839
// Resolve eager resolvables before when the transition starts
3940
this.transition.onStart({}, $eagerResolvePath, { priority: 1000 });
4041
// Resolve lazy resolvables before each state is entered
41-
this.transition.onEnter({}, $lazyResolveEnteringState, { priority: 1000 });
42+
this.transition.onEnter({ entering: val(true) }, $lazyResolveEnteringState, { priority: 1000 });
4243
}
4344
}

src/transition/hookBuilder.ts

+72-66
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,12 @@
33
import {IInjectable, extend, tail, assertPredicate, unnestR, flatten, identity} from "../common/common";
44
import {isArray} from "../common/predicates";
55

6-
import {TransitionOptions, TransitionHookOptions, IHookRegistry, TreeChanges, IEventHook, ITransitionService} from "./interface";
6+
import {TransitionOptions, TransitionHookOptions, IHookRegistry, TreeChanges, IEventHook, ITransitionService, IMatchingNodes} from "./interface";
77

88
import {Transition, TransitionHook} from "./module";
99
import {State} from "../state/module";
1010
import {Node} from "../path/module";
1111

12-
interface IToFrom {
13-
to: State;
14-
from: State;
15-
}
16-
1712
/**
1813
* This class returns applicable TransitionHooks for a specific Transition instance.
1914
*
@@ -36,7 +31,6 @@ export class HookBuilder {
3631
toState: State;
3732
fromState: State;
3833

39-
4034
constructor(private $transitions: ITransitionService, private transition: Transition, private baseHookOptions: TransitionHookOptions) {
4135
this.treeChanges = transition.treeChanges();
4236
this.toState = tail(this.treeChanges.to).state;
@@ -48,15 +42,14 @@ export class HookBuilder {
4842
// onBefore/onStart/onFinish/onSuccess/onError returns an array of hooks
4943
// onExit/onRetain/onEnter returns an array of arrays of hooks
5044

51-
getOnBeforeHooks = () => this._buildTransitionHooks("onBefore", {}, { async: false });
52-
getOnStartHooks = () => this._buildTransitionHooks("onStart");
53-
getOnExitHooks = () => this._buildNodeHooks("onExit", this.treeChanges.exiting.reverse(), (node) => this._toFrom({ from: node.state }));
54-
getOnRetainHooks = () => this._buildNodeHooks("onRetain", this.treeChanges.retained, (node) => this._toFrom());
55-
getOnEnterHooks = () => this._buildNodeHooks("onEnter", this.treeChanges.entering, (node) => this._toFrom({ to: node.state }));
56-
getOnFinishHooks = () => this._buildTransitionHooks("onFinish", { $treeChanges$: this.treeChanges });
57-
getOnSuccessHooks = () => this._buildTransitionHooks("onSuccess", {}, {async: false, rejectIfSuperseded: false});
58-
getOnErrorHooks = () => this._buildTransitionHooks("onError", {}, {async: false, rejectIfSuperseded: false});
59-
45+
getOnBeforeHooks = () => this._buildNodeHooks("onBefore", "to", tupleSort(), undefined, { async: false });
46+
getOnStartHooks = () => this._buildNodeHooks("onStart", "to", tupleSort());
47+
getOnExitHooks = () => this._buildNodeHooks("onExit", "exiting", tupleSort(true), (node) => ({ $state$: node.state }));
48+
getOnRetainHooks = () => this._buildNodeHooks("onRetain", "retained", tupleSort(), (node) => ({ $state$: node.state }));
49+
getOnEnterHooks = () => this._buildNodeHooks("onEnter", "entering", tupleSort(), (node) => ({ $state$: node.state }));
50+
getOnFinishHooks = () => this._buildNodeHooks("onFinish", "to", tupleSort(), (node) => ({ $treeChanges$: this.treeChanges }));
51+
getOnSuccessHooks = () => this._buildNodeHooks("onSuccess", "to", tupleSort(), undefined, {async: false, rejectIfSuperseded: false});
52+
getOnErrorHooks = () => this._buildNodeHooks("onError", "to", tupleSort(), undefined, {async: false, rejectIfSuperseded: false});
6053

6154
asyncHooks() {
6255
let onStartHooks = this.getOnStartHooks();
@@ -65,75 +58,88 @@ export class HookBuilder {
6558
let onEnterHooks = this.getOnEnterHooks();
6659
let onFinishHooks = this.getOnFinishHooks();
6760

68-
return flatten([onStartHooks, onExitHooks, onRetainHooks, onEnterHooks, onFinishHooks]).filter(identity);
69-
}
70-
71-
private _toFrom(toFromOverride?): IToFrom {
72-
return extend({ to: this.toState, from: this.fromState }, toFromOverride);
61+
let asyncHooks = [onStartHooks, onExitHooks, onRetainHooks, onEnterHooks, onFinishHooks];
62+
return asyncHooks.reduce(unnestR, []).filter(identity);
7363
}
7464

7565
/**
7666
* Returns an array of newly built TransitionHook objects.
7767
*
78-
* Builds a TransitionHook which cares about the entire Transition, for instance, onActivate
79-
* Finds all registered IEventHooks which matched the hookType and toFrom criteria.
80-
* A TransitionHook is then built from each IEventHook with the context, locals, and options provided.
81-
*/
82-
private _buildTransitionHooks(hookType: string, locals = {}, options: TransitionHookOptions = {}) {
83-
let context = this.treeChanges.to, node = tail(context);
84-
options.traceData = { hookType, context };
85-
86-
const transitionHook = eventHook => this.buildHook(node, eventHook.callback, locals, options);
87-
return this._matchingHooks(hookType, this._toFrom()).map(transitionHook);
88-
}
89-
90-
/**
91-
* Returns an 2 dimensional array of newly built TransitionHook objects.
92-
* Each inner array contains the hooks for a node in the Path.
68+
* - Finds all IEventHooks registered for the given `hookType` which matched the transition's [[TreeChanges]].
69+
* - Finds [[Node]] (or `Node[]`) to use as the TransitionHook context(s)
70+
* - For each of the [[Node]]s, creates a TransitionHook
9371
*
94-
* For each Node in the Path:
95-
* Builds the toFrom criteria
96-
* Finds all registered IEventHooks which matched the hookType and toFrom criteria.
97-
* A TransitionHook is then built from each IEventHook with the context, locals, and options provided.
72+
* @param hookType the name of the hook registration function, e.g., 'onEnter', 'onFinish'.
73+
* @param matchingNodesProp selects which [[Node]]s from the [[IMatchingNodes]] object to create hooks for.
74+
* @param getLocals a function which accepts a [[Node]] and returns additional locals to provide to the hook as injectables
75+
* @param sortHooksFn a function which compares two HookTuple and returns <1, 0, or >1
76+
* @param options any specific Transition Hook Options
9877
*/
99-
private _buildNodeHooks(hookType: string, path: Node[], toFromFn: (node: Node) => IToFrom, locals: any = {}, options: TransitionHookOptions = {}) {
100-
const hooksForNode = (node: Node) => {
101-
let toFrom = toFromFn(node);
102-
options.traceData = { hookType, context: node };
103-
locals.$state$ = node.state;
104-
105-
const transitionHook = eventHook => this.buildHook(node, eventHook.callback, locals, options);
106-
return this._matchingHooks(hookType, toFrom).map(transitionHook);
78+
private _buildNodeHooks(hookType: string,
79+
matchingNodesProp: string,
80+
sortHooksFn: (l: HookTuple, r: HookTuple) => number,
81+
getLocals: (node: Node) => any = (node) => ({}),
82+
options: TransitionHookOptions = {}): TransitionHook[] {
83+
84+
// Find all the matching registered hooks for a given hook type
85+
let matchingHooks = this._matchingHooks(hookType, this.treeChanges);
86+
if (!matchingHooks) return [];
87+
88+
const makeTransitionHooks = (hook: IEventHook) => {
89+
// Fetch the Nodes that caused this hook to match.
90+
let matches: IMatchingNodes = hook.matches(this.treeChanges);
91+
// Select the Node[] that will be used as TransitionHook context objects
92+
let nodes: Node[] = matches[matchingNodesProp];
93+
94+
// Return an array of HookTuples
95+
return nodes.map(node => {
96+
let _options = extend({ traceData: { hookType, context: node} }, this.baseHookOptions, options);
97+
let transitionHook = new TransitionHook(hook.callback, getLocals(node), node.resolveContext, _options);
98+
return <HookTuple> { hook, node, transitionHook };
99+
});
107100
};
108101

109-
return path.map(hooksForNode);
110-
}
111-
112-
/** Given a node and a callback function, builds a TransitionHook */
113-
buildHook(node: Node, fn: IInjectable, locals?, options: TransitionHookOptions = {}): TransitionHook {
114-
let _options = extend({}, this.baseHookOptions, options);
115-
return new TransitionHook(fn, extend({}, locals), node.resolveContext, _options);
102+
return matchingHooks.map(makeTransitionHooks)
103+
.reduce(unnestR, [])
104+
.sort(sortHooksFn)
105+
.map(tuple => tuple.transitionHook);
116106
}
117107

118-
119108
/**
120-
* returns an array of the IEventHooks from:
109+
* Finds all IEventHooks from:
121110
* - The Transition object instance hook registry
122111
* - The TransitionService ($transitions) global hook registry
112+
*
123113
* which matched:
124114
* - the eventType
125-
* - the matchCriteria to state
126-
* - the matchCriteria from state
115+
* - the matchCriteria (to, from, exiting, retained, entering)
116+
*
117+
* @returns an array of matched [[IEventHook]]s
127118
*/
128-
private _matchingHooks(hookName: string, matchCriteria: IToFrom): IEventHook[] {
129-
const matchFilter = hook => hook.matches(matchCriteria.to, matchCriteria.from);
130-
const prioritySort = (l, r) => r.priority - l.priority;
131-
119+
private _matchingHooks(hookName: string, treeChanges: TreeChanges): IEventHook[] {
132120
return [ this.transition, this.$transitions ] // Instance and Global hook registries
133121
.map((reg: IHookRegistry) => reg.getHooks(hookName)) // Get named hooks from registries
134122
.filter(assertPredicate(isArray, `broken event named: ${hookName}`)) // Sanity check
135-
.reduce(unnestR) // Un-nest IEventHook[][] to IEventHook[] array
136-
.filter(matchFilter) // Only those satisfying matchCriteria
137-
.sort(prioritySort); // Order them by .priority field
123+
.reduce(unnestR, []) // Un-nest IEventHook[][] to IEventHook[] array
124+
.filter(hook => hook.matches(treeChanges)); // Only those satisfying matchCriteria
138125
}
139126
}
127+
128+
interface HookTuple { hook: IEventHook, node: Node, transitionHook: TransitionHook }
129+
130+
/**
131+
* A factory for a sort function for HookTuples.
132+
*
133+
* The sort function first compares the Node depth (how deep in the state tree a node is), then compares
134+
* the EventHook priority.
135+
*
136+
* @param reverseDepthSort a boolean, when true, reverses the sort order for the node depth
137+
* @returns a tuple sort function
138+
*/
139+
function tupleSort(reverseDepthSort = false) {
140+
return function nodeDepthThenPriority(l: HookTuple, r: HookTuple): number {
141+
let factor = reverseDepthSort ? -1 : 1;
142+
let depthDelta = (l.node.state.path.length - r.node.state.path.length) * factor;
143+
return depthDelta !== 0 ? depthDelta : r.hook.priority - l.hook.priority;
144+
}
145+
}

src/transition/hookRegistry.ts

+41-13
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,31 @@
11
/** @module transition */ /** for typedoc */
2-
import {IInjectable, extend, removeFrom} from "../common/common";
2+
import {IInjectable, extend, removeFrom, anyTrueR, allTrueR, tail} from "../common/common";
33
import {isString, isFunction} from "../common/predicates";
44
import {val} from "../common/hof";
5+
import {Node} from "../path/node";
56

6-
import {IMatchCriteria, IStateMatch, IEventHook, IHookRegistry, IHookRegistration} from "./interface";
7-
7+
import {IMatchCriteria, IStateMatch, IEventHook, IHookRegistry, IHookRegistration, TreeChanges, MatchCriterion, IMatchingNodes} from "./interface";
88
import {Glob, State} from "../state/module";
99

1010
/**
1111
* Determines if the given state matches the matchCriteria
1212
* @param state a State Object to test against
13-
* @param matchCriteria {string|array|function}
13+
* @param criterion
1414
* - If a string, matchState uses the string as a glob-matcher against the state name
1515
* - If an array (of strings), matchState uses each string in the array as a glob-matchers against the state name
1616
* and returns a positive match if any of the globs match.
1717
* - If a function, matchState calls the function with the state and returns true if the function's result is truthy.
1818
* @returns {boolean}
1919
*/
20-
export function matchState(state: State, matchCriteria: (string|IStateMatch)) {
21-
let toMatch = isString(matchCriteria) ? [matchCriteria] : matchCriteria;
20+
export function matchState(state: State, criterion: MatchCriterion) {
21+
let toMatch = isString(criterion) ? [criterion] : criterion;
2222

2323
function matchGlobs(_state) {
24-
for (let i = 0; i < toMatch.length; i++) {
25-
let glob = Glob.fromString(toMatch[i]);
24+
let globStrings = <string[]> toMatch;
25+
for (let i = 0; i < globStrings.length; i++) {
26+
let glob = Glob.fromString(globStrings[i]);
2627

27-
if ((glob && glob.matches(_state.name)) || (!glob && toMatch[i] === _state.name)) {
28+
if ((glob && glob.matches(_state.name)) || (!glob && globStrings[i] === _state.name)) {
2829
return true;
2930
}
3031
}
@@ -41,14 +42,41 @@ export class EventHook implements IEventHook {
4142
matchCriteria: IMatchCriteria;
4243
priority: number;
4344

44-
constructor(matchCriteria: IMatchCriteria, callback: IInjectable, options: { priority: number } = <any>{}) {
45+
constructor(matchCriteria: IMatchCriteria, callback: IInjectable, options: { priority?: number } = <any>{}) {
4546
this.callback = callback;
46-
this.matchCriteria = extend({to: val(true), from: val(true)}, matchCriteria);
47+
this.matchCriteria = extend({ to: true, from: true, exiting: true, retained: true, entering: true }, matchCriteria);
4748
this.priority = options.priority || 0;
4849
}
4950

50-
matches(to: State, from: State) {
51-
return <boolean> matchState(to, this.matchCriteria.to) && matchState(from, this.matchCriteria.from);
51+
private static _matchingNodes(nodes: Node[], criterion: MatchCriterion): Node[] {
52+
if (criterion === true) return nodes;
53+
let matching = nodes.filter(node => matchState(node.state, criterion));
54+
return matching.length ? matching : null;
55+
}
56+
57+
/**
58+
* Determines if this hook's [[matchCriteria]] match the given [[TreeChanges]]
59+
*
60+
* @returns an IMatchingNodes object, or null. If an IMatchingNodes object is returned, its values
61+
* are the matching [[Node]]s for each [[MatchCriterion]] (to, from, exiting, retained, entering)
62+
*/
63+
matches(treeChanges: TreeChanges): IMatchingNodes {
64+
let mc = this.matchCriteria, _matchingNodes = EventHook._matchingNodes;
65+
66+
let matches = {
67+
to: _matchingNodes([tail(treeChanges.to)], mc.to),
68+
from: _matchingNodes([tail(treeChanges.from)], mc.from),
69+
exiting: _matchingNodes(treeChanges.exiting, mc.exiting),
70+
retained: _matchingNodes(treeChanges.retained, mc.retained),
71+
entering: _matchingNodes(treeChanges.entering, mc.entering),
72+
};
73+
74+
// Check if all the criteria matched the TreeChanges object
75+
let allMatched: boolean = ["to", "from", "exiting", "retained", "entering"]
76+
.map(prop => matches[prop])
77+
.reduce(allTrueR, true);
78+
79+
return allMatched ? matches : null;
5280
}
5381
}
5482

src/transition/interface.ts

+39-15
Original file line numberDiff line numberDiff line change
@@ -576,9 +576,10 @@ export type IStateMatch = Predicate<State>
576576
* This object is used to configure whether or not a Transition Hook is invoked for a particular transition,
577577
* based on the Transition's "to state" and "from state".
578578
*
579-
* The `to` and `from` can be state globs, or a function that takes a state.
580-
* Both `to` and `from` are optional. If one of these is omitted, it is replaced with the
581-
* function: `function() { return true; }`, which effectively matches any state.
579+
* Each property (`to`, `from`, `exiting`, `retained`, and `entering`) can be state globs, a function that takes a
580+
* state, or a boolean (see [[MatchCriterion]])
581+
*
582+
* All properties are optional. If any property is omitted, it is replaced with the value `true`, and always matches.
582583
*
583584
* @example
584585
* ```js
@@ -620,24 +621,47 @@ export type IStateMatch = Predicate<State>
620621
* }
621622
* }
622623
* ```
624+
*
625+
* @example
626+
* ```js
627+
*
628+
* // This matches a transition that is exiting `parent.child`
629+
* var match = {
630+
* exiting: 'parent.child'
631+
* }
632+
* ```
623633
*/
624634
export interface IMatchCriteria {
625-
/**
626-
* A glob string that matches the 'to' state's name.
627-
* Or, a function with the signature `function(state) {}` which should return a boolean to indicate if the state matches.
628-
*/
629-
to?: (string|IStateMatch);
635+
/** A [[MatchCriterion]] to match the destination state */
636+
to?: MatchCriterion;
637+
/** A [[MatchCriterion]] to match the original (from) state */
638+
from?: MatchCriterion;
639+
/** A [[MatchCriterion]] to match any state that would be exiting */
640+
exiting?: MatchCriterion;
641+
/** A [[MatchCriterion]] to match any state that would be retained */
642+
retained?: MatchCriterion;
643+
/** A [[MatchCriterion]] to match any state that would be entering */
644+
entering?: MatchCriterion;
645+
}
630646

631-
/**
632-
* A glob string that matches the 'from' state's name.
633-
* Or, a function with the signature `function(State) { return boolean; }` which should return a boolean to
634-
* indicate if the state matches.
635-
*/
636-
from?: (string|IStateMatch);
647+
export interface IMatchingNodes {
648+
to: Node[];
649+
from: Node[];
650+
exiting: Node[];
651+
retained: Node[];
652+
entering: Node[];
637653
}
638654

655+
/**
656+
* A glob string that matches the name of a state
657+
* Or, a function with the signature `function(state) { return matches; }`
658+
* which should return a boolean to indicate if a state matches.
659+
* Or, `true` to match anything
660+
*/
661+
export type MatchCriterion = (string|IStateMatch|boolean)
662+
639663
export interface IEventHook {
640664
callback: IInjectable;
641665
priority: number;
642-
matches: (a: State, b: State) => boolean;
666+
matches: (treeChanges: TreeChanges) => IMatchingNodes;
643667
}

0 commit comments

Comments
 (0)