From f2bf4bd709336fe29dffa501199bf8bcf9126009 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Sat, 27 Sep 2014 13:14:17 -0700 Subject: [PATCH 01/14] [removed] RouteStore [added] Router.PathState for keeping track of the current URL path [added] Router.RouteLookup for looking up routes [added] Router.Transitions for transitioning to other routes [added] Pluggable scroll behaviors [changed] => [removed] [removed] Router.transitionTo, Router.replaceWith, Router.goBack --- modules/actions/LocationActions.js | 58 +--- modules/components/Link.js | 12 +- modules/components/Routes.js | 431 +-------------------------- modules/index.js | 9 +- modules/locations/DefaultLocation.js | 5 - modules/locations/HashLocation.js | 64 ++-- modules/locations/HistoryLocation.js | 64 +++- modules/locations/MemoryLocation.js | 43 ++- modules/locations/RefreshLocation.js | 10 +- modules/mixins/ActiveDelegate.js | 17 +- modules/mixins/ActiveState.js | 14 +- modules/mixins/PathDelegate.js | 115 +++++++ modules/mixins/PathListener.js | 73 ----- modules/mixins/PathState.js | 116 +++++++ modules/mixins/RouteContainer.js | 164 ++++++++++ modules/mixins/RouteLookup.js | 44 +++ modules/mixins/ScrollDelegate.js | 39 +++ modules/mixins/ScrollState.js | 103 +++++++ modules/mixins/TransitionHandler.js | 418 ++++++++++++++++++++++++++ modules/mixins/Transitions.js | 35 +++ modules/stores/PathStore.js | 101 +------ modules/stores/RouteStore.js | 156 ---------- modules/utils/Transition.js | 6 +- modules/utils/getWindowPath.js | 1 - modules/utils/isAbsoluteURL.js | 11 - modules/utils/makeHref.js | 18 -- modules/utils/makePath.js | 28 -- modules/utils/reversedArray.js | 5 + 28 files changed, 1222 insertions(+), 938 deletions(-) delete mode 100644 modules/locations/DefaultLocation.js create mode 100644 modules/mixins/PathDelegate.js delete mode 100644 modules/mixins/PathListener.js create mode 100644 modules/mixins/PathState.js create mode 100644 modules/mixins/RouteContainer.js create mode 100644 modules/mixins/RouteLookup.js create mode 100644 modules/mixins/ScrollDelegate.js create mode 100644 modules/mixins/ScrollState.js create mode 100644 modules/mixins/TransitionHandler.js create mode 100644 modules/mixins/Transitions.js delete mode 100644 modules/stores/RouteStore.js delete mode 100644 modules/utils/isAbsoluteURL.js delete mode 100644 modules/utils/makeHref.js delete mode 100644 modules/utils/makePath.js create mode 100644 modules/utils/reversedArray.js diff --git a/modules/actions/LocationActions.js b/modules/actions/LocationActions.js index 0cf19f75bc..46a1be6634 100644 --- a/modules/actions/LocationActions.js +++ b/modules/actions/LocationActions.js @@ -1,69 +1,27 @@ -var LocationDispatcher = require('../dispatchers/LocationDispatcher'); -var isAbsoluteURL = require('../utils/isAbsoluteURL'); -var makePath = require('../utils/makePath'); - -function loadURL(url) { - window.location = url; -} - /** * Actions that modify the URL. */ var LocationActions = { - PUSH: 'push', - REPLACE: 'replace', - POP: 'pop', - UPDATE_SCROLL: 'update-scroll', - /** - * Transitions to the URL specified in the arguments by pushing - * a new URL onto the history stack. + * Indicates a location is being setup for the first time. */ - transitionTo: function (to, params, query) { - if (isAbsoluteURL(to)) { - loadURL(to); - } else { - LocationDispatcher.handleViewAction({ - type: LocationActions.PUSH, - path: makePath(to, params, query) - }); - } - }, + SETUP: 'setup', /** - * Transitions to the URL specified in the arguments by replacing - * the current URL in the history stack. + * Indicates a new location is being pushed to the history stack. */ - replaceWith: function (to, params, query) { - if (isAbsoluteURL(to)) { - loadURL(to); - } else { - LocationDispatcher.handleViewAction({ - type: LocationActions.REPLACE, - path: makePath(to, params, query) - }); - } - }, + PUSH: 'push', /** - * Transitions to the previous URL. + * Indicates the current location should be replaced. */ - goBack: function () { - LocationDispatcher.handleViewAction({ - type: LocationActions.POP - }); - }, + REPLACE: 'replace', /** - * Updates the window's scroll position to the last known position - * for the current URL path. + * Indicates the most recent entry should be removed from the history stack. */ - updateScroll: function () { - LocationDispatcher.handleViewAction({ - type: LocationActions.UPDATE_SCROLL - }); - } + POP: 'pop' }; diff --git a/modules/components/Link.js b/modules/components/Link.js index fe30013958..1dd114d6d4 100644 --- a/modules/components/Link.js +++ b/modules/components/Link.js @@ -1,10 +1,10 @@ var React = require('react'); +var warning = require('react/lib/warning'); var ActiveState = require('../mixins/ActiveState'); -var transitionTo = require('../actions/LocationActions').transitionTo; +var RouteLookup = require('../mixins/RouteLookup'); +var Transitions = require('../mixins/Transitions'); var withoutProperties = require('../utils/withoutProperties'); var hasOwnProperty = require('../utils/hasOwnProperty'); -var makeHref = require('../utils/makeHref'); -var warning = require('react/lib/warning'); function isLeftClickEvent(event) { return event.button === 0; @@ -51,7 +51,7 @@ var Link = React.createClass({ displayName: 'Link', - mixins: [ ActiveState ], + mixins: [ ActiveState, RouteLookup, Transitions ], statics: { @@ -99,7 +99,7 @@ var Link = React.createClass({ * Returns the value of the "href" attribute to use on the DOM element. */ getHref: function () { - return makeHref(this.props.to, Link.getParams(this.props), this.props.query); + return this.makeHref(this.props.to, Link.getParams(this.props), this.props.query); }, /** @@ -145,7 +145,7 @@ var Link = React.createClass({ event.preventDefault(); if (allowTransition) - transitionTo(this.props.to, Link.getParams(this.props), this.props.query); + this.transitionTo(this.props.to, Link.getParams(this.props), this.props.query); }, render: function () { diff --git a/modules/components/Routes.js b/modules/components/Routes.js index 3a437048a2..6296d8a747 100644 --- a/modules/components/Routes.js +++ b/modules/components/Routes.js @@ -1,60 +1,5 @@ var React = require('react'); -var warning = require('react/lib/warning'); -var copyProperties = require('react/lib/copyProperties'); -var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; -var LocationActions = require('../actions/LocationActions'); -var Route = require('../components/Route'); -var ActiveDelegate = require('../mixins/ActiveDelegate'); -var PathListener = require('../mixins/PathListener'); -var RouteStore = require('../stores/RouteStore'); -var Path = require('../utils/Path'); -var Promise = require('../utils/Promise'); -var Redirect = require('../utils/Redirect'); -var Transition = require('../utils/Transition'); - -/** - * The ref name that can be used to reference the active route component. - */ -var REF_NAME = '__activeRoute__'; - -/** - * The default handler for aborted transitions. Redirects replace - * the current URL and all others roll it back. - */ -function defaultAbortedTransitionHandler(transition) { - if (!canUseDOM) - return; - - var reason = transition.abortReason; - - if (reason instanceof Redirect) { - LocationActions.replaceWith(reason.to, reason.params, reason.query); - } else { - LocationActions.goBack(); - } -} - -/** - * The default handler for errors that were thrown asynchronously - * while transitioning. The default behavior is to re-throw the - * error so that it isn't silently swallowed. - */ -function defaultTransitionErrorHandler(error) { - throw error; // This error probably originated in a transition hook. -} - -/** - * Updates the window's scroll position given the current route. - */ -function maybeUpdateScroll(routes) { - if (!canUseDOM) - return; - - var currentRoute = routes.getCurrentRoute(); - - if (!routes.props.preserveScrollPosition && currentRoute && !currentRoute.props.preserveScrollPosition) - LocationActions.updateScroll(); -} +var TransitionHandler = require('../mixins/TransitionHandler'); /** * The component configures the route hierarchy and renders the @@ -66,379 +11,19 @@ var Routes = React.createClass({ displayName: 'Routes', - mixins: [ ActiveDelegate, PathListener ], - - propTypes: { - onAbortedTransition: React.PropTypes.func.isRequired, - onTransitionError: React.PropTypes.func.isRequired, - preserveScrollPosition: React.PropTypes.bool - }, - - getDefaultProps: function () { - return { - onAbortedTransition: defaultAbortedTransitionHandler, - onTransitionError: defaultTransitionErrorHandler, - preserveScrollPosition: false - }; - }, - - getInitialState: function () { - return { - matches: [], - routes: RouteStore.registerChildren(this.props.children, this) - }; - }, - - /** - * Gets the component that is currently active. - */ - getCurrentRoute: function () { - var rootMatch = getRootMatch(this.state.matches); - return rootMatch && rootMatch.route; - }, - - /** - * Performs a depth-first search for the first route in the tree that matches - * on the given path. Returns an array of all routes in the tree leading to - * the one that matched in the format { route, params } where params is an - * object that contains the URL parameters relevant to that route. Returns - * null if no route in the tree matches the path. - * - * React.renderComponent( - * - * - * - * - * - * - * ).match('/posts/123'); => [ { route: , params: {} }, - * { route: , params: { id: '123' } } ] - */ - match: function (path) { - return findMatches(Path.withoutQuery(path), this.state.routes, this.props.defaultRoute, this.props.notFoundRoute); - }, - - updatePath: function (path) { - var self = this; - - this.dispatch(path, function (error, transition) { - if (error) { - self.props.onTransitionError(error); - } else if (transition.isAborted) { - self.props.onAbortedTransition(transition); - } else { - self.emitChange(); - maybeUpdateScroll(self); - } - }); - }, - - /** - * Performs a transition to the given path and calls callback(error, transition) - * with the Transition object when the transition is finished and the component's - * state has been updated accordingly. - * - * In a transition, the router first determines which routes are involved by - * beginning with the current route, up the route tree to the first parent route - * that is shared with the destination route, and back down the tree to the - * destination route. The willTransitionFrom hook is invoked on all route handlers - * we're transitioning away from, in reverse nesting order. Likewise, the - * willTransitionTo hook is invoked on all route handlers we're transitioning to. - * - * Both willTransitionFrom and willTransitionTo hooks may either abort or redirect - * the transition. To resolve asynchronously, they may use transition.wait(promise). - * - * Note: This function does not update the URL in a browser's location bar. - */ - dispatch: function (path, callback) { - var transition = new Transition(path); - var self = this; - - computeNextState(this, transition, function (error, nextState) { - if (error || nextState == null) - return callback(error, transition); - - self.setState(nextState, function () { - callback(null, transition); - }); - }); - }, + mixins: [ TransitionHandler ], render: function () { - if (!this.state.path) - return null; + var match = this.state.matches[0]; - var matches = this.state.matches; - if (matches.length) { - // matches[0] corresponds to the top-most match - return matches[0].route.props.handler(computeHandlerProps(matches, this.state.activeQuery)); - } else { + if (match == null) return null; - } - } - -}); - -function findMatches(path, routes, defaultRoute, notFoundRoute) { - var matches = null, route, params; - - for (var i = 0, len = routes.length; i < len; ++i) { - route = routes[i]; - - // Check the subtree first to find the most deeply-nested match. - matches = findMatches(path, route.props.children, route.props.defaultRoute, route.props.notFoundRoute); - - if (matches != null) { - var rootParams = getRootMatch(matches).params; - - params = route.props.paramNames.reduce(function (params, paramName) { - params[paramName] = rootParams[paramName]; - return params; - }, {}); - - matches.unshift(makeMatch(route, params)); - - return matches; - } - - // No routes in the subtree matched, so check this route. - params = Path.extractParams(route.props.path, path); - - if (params) - return [ makeMatch(route, params) ]; - } - - // No routes matched, so try the default route if there is one. - if (defaultRoute && (params = Path.extractParams(defaultRoute.props.path, path))) - return [ makeMatch(defaultRoute, params) ]; - - // Last attempt: does the "not found" route match? - if (notFoundRoute && (params = Path.extractParams(notFoundRoute.props.path, path))) - return [ makeMatch(notFoundRoute, params) ]; - - return matches; -} - -function makeMatch(route, params) { - return { route: route, params: params }; -} - -function hasMatch(matches, match) { - return matches.some(function (m) { - if (m.route !== match.route) - return false; - - for (var property in m.params) { - if (m.params[property] !== match.params[property]) - return false; - } - - return true; - }); -} - -function getRootMatch(matches) { - return matches[matches.length - 1]; -} -function updateMatchComponents(matches, refs) { - var i = 0, component; - while (component = refs[REF_NAME]) { - matches[i++].component = component; - refs = component.refs; + return match.route.props.handler( + this.getHandlerProps() + ); } -} -/** - * Computes the next state for the given component and calls - * callback(error, nextState) when finished. Also runs all transition - * hooks along the way. - */ -function computeNextState(component, transition, callback) { - if (component.state.path === transition.path) - return callback(); // Nothing to do! - - var currentMatches = component.state.matches; - var nextMatches = component.match(transition.path); - - warning( - nextMatches, - 'No route matches path "' + transition.path + '". Make sure you have ' + - ' somewhere in your routes' - ); - - if (!nextMatches) - nextMatches = []; - - var fromMatches, toMatches; - if (currentMatches.length) { - updateMatchComponents(currentMatches, component.refs); - - fromMatches = currentMatches.filter(function (match) { - return !hasMatch(nextMatches, match); - }); - - toMatches = nextMatches.filter(function (match) { - return !hasMatch(currentMatches, match); - }); - } else { - fromMatches = []; - toMatches = nextMatches; - } - - var query = Path.extractQuery(transition.path) || {}; - - runTransitionFromHooks(fromMatches, transition, function (error) { - if (error || transition.isAborted) - return callback(error); - - runTransitionToHooks(toMatches, transition, query, function (error) { - if (error || transition.isAborted) - return callback(error); - - var matches = currentMatches.slice(0, -fromMatches.length).concat(toMatches); - var rootMatch = getRootMatch(matches); - var params = (rootMatch && rootMatch.params) || {}; - var routes = matches.map(function (match) { - return match.route; - }); - - callback(null, { - path: transition.path, - matches: matches, - activeRoutes: routes, - activeParams: params, - activeQuery: query - }); - }); - }); -} - -/** - * Calls the willTransitionFrom hook of all handlers in the given matches - * serially in reverse with the transition object and the current instance of - * the route's handler, so that the deepest nested handlers are called first. - * Calls callback(error) when finished. - */ -function runTransitionFromHooks(matches, transition, callback) { - var hooks = reversedArray(matches).map(function (match) { - return function () { - var handler = match.route.props.handler; - - if (!transition.isAborted && handler.willTransitionFrom) - return handler.willTransitionFrom(transition, match.component); - - var promise = transition.promise; - delete transition.promise; - - return promise; - }; - }); - - runHooks(hooks, callback); -} - -/** - * Calls the willTransitionTo hook of all handlers in the given matches - * serially with the transition object and any params that apply to that - * handler. Calls callback(error) when finished. - */ -function runTransitionToHooks(matches, transition, query, callback) { - var hooks = matches.map(function (match) { - return function () { - var handler = match.route.props.handler; - - if (!transition.isAborted && handler.willTransitionTo) - handler.willTransitionTo(transition, match.params, query); - - var promise = transition.promise; - delete transition.promise; - - return promise; - }; - }); - - runHooks(hooks, callback); -} - -/** - * Runs all hook functions serially and calls callback(error) when finished. - * A hook may return a promise if it needs to execute asynchronously. - */ -function runHooks(hooks, callback) { - try { - var promise = hooks.reduce(function (promise, hook) { - // The first hook to use transition.wait makes the rest - // of the transition async from that point forward. - return promise ? promise.then(hook) : hook(); - }, null); - } catch (error) { - return callback(error); // Sync error. - } - - if (promise) { - // Use setTimeout to break the promise chain. - promise.then(function () { - setTimeout(callback); - }, function (error) { - setTimeout(function () { - callback(error); - }); - }); - } else { - callback(); - } -} - -/** - * Given an array of matches as returned by findMatches, return a descriptor for - * the handler hierarchy specified by the route. - */ -function computeHandlerProps(matches, query) { - var props = { - ref: null, - key: null, - params: null, - query: null, - activeRouteHandler: returnNull - }; - - var childHandler; - reversedArray(matches).forEach(function (match) { - var route = match.route; - - props = Route.getUnreservedProps(route.props); - - props.ref = REF_NAME; - props.params = match.params; - props.query = query; - - if (route.props.addHandlerKey) - props.key = Path.injectParams(route.props.path, match.params); - - if (childHandler) { - props.activeRouteHandler = childHandler; - } else { - props.activeRouteHandler = returnNull; - } - - childHandler = function (props, addedProps) { - if (arguments.length > 2 && typeof arguments[2] !== 'undefined') - throw new Error('Passing children to a route handler is not supported'); - - return route.props.handler(copyProperties(props, addedProps)); - }.bind(this, props); - }); - - return props; -} - -function returnNull() { - return null; -} - -function reversedArray(array) { - return array.slice(0).reverse(); -} +}); module.exports = Routes; diff --git a/modules/index.js b/modules/index.js index 41866e1196..81813a5864 100644 --- a/modules/index.js +++ b/modules/index.js @@ -1,7 +1,3 @@ -exports.goBack = require('./actions/LocationActions').goBack; -exports.replaceWith = require('./actions/LocationActions').replaceWith; -exports.transitionTo = require('./actions/LocationActions').transitionTo; - exports.DefaultRoute = require('./components/DefaultRoute'); exports.Link = require('./components/Link'); exports.NotFoundRoute = require('./components/NotFoundRoute'); @@ -11,5 +7,6 @@ exports.Routes = require('./components/Routes'); exports.ActiveState = require('./mixins/ActiveState'); exports.AsyncState = require('./mixins/AsyncState'); - -exports.makeHref = require('./utils/makeHref'); +exports.PathState = require('./mixins/PathState'); +exports.RouteLookup = require('./mixins/RouteLookup'); +exports.Transitions = require('./mixins/Transitions'); diff --git a/modules/locations/DefaultLocation.js b/modules/locations/DefaultLocation.js deleted file mode 100644 index 8b739102c0..0000000000 --- a/modules/locations/DefaultLocation.js +++ /dev/null @@ -1,5 +0,0 @@ -var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; - -module.exports = process.env.NODE_ENV === 'test' || !canUseDOM - ? require('./MemoryLocation') - : require('./HashLocation'); diff --git a/modules/locations/HashLocation.js b/modules/locations/HashLocation.js index 3b7410d1d6..f6dc5a3d10 100644 --- a/modules/locations/HashLocation.js +++ b/modules/locations/HashLocation.js @@ -1,9 +1,11 @@ var invariant = require('react/lib/invariant'); var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; +var LocationActions = require('../actions/LocationActions'); +var LocationDispatcher = require('../dispatchers/LocationDispatcher'); var getWindowPath = require('../utils/getWindowPath'); function getHashPath() { - return window.location.hash.substr(1); + return window.location.hash.substr(1) || '/'; } function ensureSlash() { @@ -12,62 +14,84 @@ function ensureSlash() { if (path.charAt(0) === '/') return true; - HashLocation.replace('/' + path); + HashLocation.replace('/' + path, _actionSender); return false; } -var _onChange; +var _actionType, _actionSender; -function handleHashChange() { - if (ensureSlash()) - _onChange(); +function onHashChange() { + if (ensureSlash()) { + LocationDispatcher.handleViewAction({ + type: _actionType, + path: getHashPath(), + sender: _actionSender || window + }); + + _actionSender = null; + } } +var _isSetup = false; + /** * A Location that uses `window.location.hash`. */ var HashLocation = { - setup: function (onChange) { + setup: function () { + if (_isSetup) + return; + invariant( canUseDOM, 'You cannot use HashLocation in an environment with no DOM' ); - _onChange = onChange; - - ensureSlash(); - if (window.addEventListener) { - window.addEventListener('hashchange', handleHashChange, false); + window.addEventListener('hashchange', onHashChange, false); } else { - window.attachEvent('onhashchange', handleHashChange); + window.attachEvent('onhashchange', onHashChange); } + + LocationDispatcher.handleViewAction({ + type: LocationActions.SETUP, + path: getHashPath(), + sender: window + }); + + _isSetup = true; }, teardown: function () { if (window.removeEventListener) { - window.removeEventListener('hashchange', handleHashChange, false); + window.removeEventListener('hashchange', onHashChange, false); } else { - window.detachEvent('onhashchange', handleHashChange); + window.detachEvent('onhashchange', onHashChange); } + + _isSetup = false; }, - push: function (path) { + push: function (path, sender) { + _actionType = LocationActions.PUSH; + _actionSender = sender; window.location.hash = path; }, - replace: function (path) { + replace: function (path, sender) { + _actionType = LocationActions.REPLACE; + _actionSender = sender; window.location.replace(getWindowPath() + '#' + path); }, - pop: function () { + pop: function (sender) { + _actionType = LocationActions.POP; + _actionSender = sender; window.history.back(); }, - getCurrentPath: getHashPath, - toString: function () { return ''; } diff --git a/modules/locations/HistoryLocation.js b/modules/locations/HistoryLocation.js index 98a9a3b64d..90cdd9b0e1 100644 --- a/modules/locations/HistoryLocation.js +++ b/modules/locations/HistoryLocation.js @@ -1,53 +1,87 @@ var invariant = require('react/lib/invariant'); var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; +var LocationActions = require('../actions/LocationActions'); +var LocationDispatcher = require('../dispatchers/LocationDispatcher'); var getWindowPath = require('../utils/getWindowPath'); -var _onChange; +var _actionSender; + +function onPopState() { + LocationDispatcher.handleViewAction({ + type: LocationActions.POP, + path: getWindowPath(), + sender: _actionSender || window + }); + + _actionSender = null; +} + +var _isSetup = false; /** * A Location that uses HTML5 history. */ var HistoryLocation = { - setup: function (onChange) { + setup: function () { + if (_isSetup) + return; + invariant( canUseDOM, 'You cannot use HistoryLocation in an environment with no DOM' ); - _onChange = onChange; - if (window.addEventListener) { - window.addEventListener('popstate', _onChange, false); + window.addEventListener('popstate', onPopState, false); } else { - window.attachEvent('popstate', _onChange); + window.attachEvent('popstate', onPopState); } + + LocationDispatcher.handleViewAction({ + type: LocationActions.SETUP, + path: getWindowPath(), + sender: window + }); + + _isSetup = true; }, teardown: function () { if (window.removeEventListener) { - window.removeEventListener('popstate', _onChange, false); + window.removeEventListener('popstate', onPopState, false); } else { - window.detachEvent('popstate', _onChange); + window.detachEvent('popstate', onPopState); } + + _isSetup = false; }, - push: function (path) { + push: function (path, sender) { window.history.pushState({ path: path }, '', path); - _onChange(); + + LocationDispatcher.handleViewAction({ + type: LocationActions.PUSH, + path: getWindowPath(), + sender: sender + }); }, - replace: function (path) { + replace: function (path, sender) { window.history.replaceState({ path: path }, '', path); - _onChange(); + + LocationDispatcher.handleViewAction({ + type: LocationActions.REPLACE, + path: getWindowPath(), + sender: sender + }); }, - pop: function () { + pop: function (sender) { + _actionSender = sender; window.history.back(); }, - getCurrentPath: getWindowPath, - toString: function () { return ''; } diff --git a/modules/locations/MemoryLocation.js b/modules/locations/MemoryLocation.js index 57f39b099d..9908adc600 100644 --- a/modules/locations/MemoryLocation.js +++ b/modules/locations/MemoryLocation.js @@ -1,30 +1,48 @@ var warning = require('react/lib/warning'); +var LocationActions = require('../actions/LocationActions'); +var LocationDispatcher = require('../dispatchers/LocationDispatcher'); var _lastPath = null; var _currentPath = null; -var _onChange; + +function getCurrentPath() { + return _currentPath || '/'; +} /** * A Location that does not require a DOM. */ var MemoryLocation = { - setup: function (onChange) { - _onChange = onChange; + setup: function () { + LocationDispatcher.handleViewAction({ + type: LocationActions.SETUP, + path: getCurrentPath() + }); }, - push: function (path) { + push: function (path, sender) { _lastPath = _currentPath; _currentPath = path; - _onChange(); + + LocationDispatcher.handleViewAction({ + type: LocationActions.PUSH, + path: getCurrentPath(), + sender: sender + }); }, - replace: function (path) { + replace: function (path, sender) { _currentPath = path; - _onChange(); + + LocationDispatcher.handleViewAction({ + type: LocationActions.REPLACE, + path: getCurrentPath(), + sender: sender + }); }, - pop: function () { + pop: function (sender) { warning( _lastPath != null, 'You cannot use MemoryLocation to go back more than once' @@ -32,11 +50,12 @@ var MemoryLocation = { _currentPath = _lastPath; _lastPath = null; - _onChange(); - }, - getCurrentPath: function () { - return _currentPath || '/'; + LocationDispatcher.handleViewAction({ + type: LocationActions.POP, + path: getCurrentPath(), + sender: sender + }); }, toString: function () { diff --git a/modules/locations/RefreshLocation.js b/modules/locations/RefreshLocation.js index 8112e797d1..c930c6c08d 100644 --- a/modules/locations/RefreshLocation.js +++ b/modules/locations/RefreshLocation.js @@ -1,5 +1,7 @@ var invariant = require('react/lib/invariant'); var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; +var LocationActions = require('../actions/LocationActions'); +var LocationDispatcher = require('../dispatchers/LocationDispatcher'); var getWindowPath = require('../utils/getWindowPath'); /** @@ -14,6 +16,12 @@ var RefreshLocation = { canUseDOM, 'You cannot use RefreshLocation in an environment with no DOM' ); + + LocationDispatcher.handleViewAction({ + type: LocationActions.SETUP, + path: getWindowPath(), + sender: window + }); }, push: function (path) { @@ -28,8 +36,6 @@ var RefreshLocation = { window.history.back(); }, - getCurrentPath: getWindowPath, - toString: function () { return ''; } diff --git a/modules/mixins/ActiveDelegate.js b/modules/mixins/ActiveDelegate.js index 4355d28d16..d8036efa46 100644 --- a/modules/mixins/ActiveDelegate.js +++ b/modules/mixins/ActiveDelegate.js @@ -43,19 +43,24 @@ var ActiveDelegate = { }; }, + getInitialState: function () { + return { + activeRoutes: [], + activeParams: {}, + activeQuery: {} + }; + }, + /** * Returns true if the route with the given name, URL parameters, and * query are all currently active. */ isActive: function (routeName, params, query) { - var activeRoutes = this.state.activeRoutes || []; - var activeParams = this.state.activeParams || {}; - var activeQuery = this.state.activeQuery || {}; - - var isActive = routeIsActive(activeRoutes, routeName) && paramsAreActive(activeParams, params); + var isActive = routeIsActive(this.state.activeRoutes, routeName) && + paramsAreActive(this.state.activeParams, params); if (query) - return isActive && queryIsActive(activeQuery, query); + return isActive && queryIsActive(this.state.activeQuery, query); return isActive; } diff --git a/modules/mixins/ActiveState.js b/modules/mixins/ActiveState.js index e9cabce1e0..a4445561d0 100644 --- a/modules/mixins/ActiveState.js +++ b/modules/mixins/ActiveState.js @@ -11,7 +11,6 @@ var ActiveDelegate = require('./ActiveDelegate'); * 2. An `isActive` method they can use to check if a route, * params, and query are active. * - * * Example: * * var Tab = React.createClass({ @@ -38,24 +37,17 @@ var ActiveState = { activeDelegate: React.PropTypes.any.isRequired }, - /** - * Returns this component's ActiveDelegate component. - */ - getActiveDelegate: function () { - return this.context.activeDelegate; - }, - componentWillMount: function () { if (this.updateActiveState) this.updateActiveState(); }, componentDidMount: function () { - this.getActiveDelegate().addChangeListener(this.handleActiveStateChange); + this.context.activeDelegate.addChangeListener(this.handleActiveStateChange); }, componentWillUnmount: function () { - this.getActiveDelegate().removeChangeListener(this.handleActiveStateChange); + this.context.activeDelegate.removeChangeListener(this.handleActiveStateChange); }, handleActiveStateChange: function () { @@ -68,7 +60,7 @@ var ActiveState = { * query are all currently active. */ isActive: function (routeName, params, query) { - return this.getActiveDelegate().isActive(routeName, params, query); + return this.context.activeDelegate.isActive(routeName, params, query); } }; diff --git a/modules/mixins/PathDelegate.js b/modules/mixins/PathDelegate.js new file mode 100644 index 0000000000..0e31287563 --- /dev/null +++ b/modules/mixins/PathDelegate.js @@ -0,0 +1,115 @@ +var React = require('react'); +var invariant = require('react/lib/invariant'); +var PathState = require('./PathState'); +var RouteContainer = require('./RouteContainer'); +var HashLocation = require('../locations/HashLocation'); +var Path = require('../utils/Path'); + +/** + * A mixin for components that manage the current URL path. + */ +var PathDelegate = { + + mixins: [ PathState, RouteContainer ], + + childContextTypes: { + pathDelegate: React.PropTypes.any.isRequired + }, + + getChildContext: function () { + return { + pathDelegate: this + }; + }, + + /** + * Returns an absolute URL path created from the given route + * name, URL parameters, and query values. + */ + makePath: function (to, params, query) { + var path; + if (Path.isAbsolute(to)) { + path = Path.normalize(to); + } else { + var route = this.getRouteByName(to); + + invariant( + route, + 'Unable to find a route named "' + to + '". Make sure you have ' + + 'a defined somewhere in your ' + ); + + path = route.props.path; + } + + return Path.withQuery(Path.injectParams(path, params), query); + }, + + /** + * Returns a string that may safely be used as the href of a + * link to the route with the given name. + */ + makeHref: function (to, params, query) { + var path = this.makePath(to, params, query); + + if (this.getLocation() === HashLocation) + return '#' + path; + + return path; + }, + + /** + * Transitions to the URL specified in the arguments by pushing + * a new URL onto the history stack. + */ + transitionTo: function (to, params, query, sender) { + sender = sender || this; + + var path = this.makePath(to, params, query); + var location = this.getLocation(); + + // If we have a location, route the transition through it. + if (location) { + location.push(path, this); + } else if (this.updatePath) { + this.updatePath(path, this); + } + }, + + /** + * Transitions to the URL specified in the arguments by replacing + * the current URL in the history stack. + */ + replaceWith: function (to, params, query, sender) { + sender = sender || this; + + var path = this.makePath(to, params, query); + var location = this.getLocation(); + + // If we have a location, route the transition through it. + if (location) { + location.replace(path, sender); + } else if (this.updatePath) { + this.updatePath(path, sender); + } + }, + + /** + * Transitions to the previous URL. + */ + goBack: function (sender) { + sender = sender || this; + + var location = this.getLocation(); + + invariant( + location, + 'You cannot goBack without a location' + ); + + location.pop(sender); + } + +}; + +module.exports = PathDelegate; diff --git a/modules/mixins/PathListener.js b/modules/mixins/PathListener.js deleted file mode 100644 index 4e5dd937c3..0000000000 --- a/modules/mixins/PathListener.js +++ /dev/null @@ -1,73 +0,0 @@ -var React = require('react'); -var DefaultLocation = require('../locations/DefaultLocation'); -var HashLocation = require('../locations/HashLocation'); -var HistoryLocation = require('../locations/HistoryLocation'); -var RefreshLocation = require('../locations/RefreshLocation'); -var PathStore = require('../stores/PathStore'); - -/** - * A hash of { name, location } pairs. - */ -var NAMED_LOCATIONS = { - hash: HashLocation, - history: HistoryLocation, - refresh: RefreshLocation -}; - -/** - * A mixin for components that listen for changes to the current - * URL path. - */ -var PathListener = { - - propTypes: { - location: function (props, propName, componentName) { - var location = props[propName]; - - if (typeof location === 'string' && !(location in NAMED_LOCATIONS)) - return new Error('Unknown location "' + location + '", see ' + componentName); - } - }, - - getDefaultProps: function () { - return { - location: DefaultLocation - }; - }, - - /** - * Gets the location object this component uses to watch for - * changes to the current URL path. - */ - getLocation: function () { - var location = this.props.location; - - if (typeof location === 'string') - return NAMED_LOCATIONS[location]; - - return location; - }, - - componentWillMount: function () { - PathStore.setup(this.getLocation()); - - if (this.updatePath) - this.updatePath(PathStore.getCurrentPath()); - }, - - componentDidMount: function () { - PathStore.addChangeListener(this.handlePathChange); - }, - - componentWillUnmount: function () { - PathStore.removeChangeListener(this.handlePathChange); - }, - - handlePathChange: function () { - if (this.isMounted() && this.updatePath) - this.updatePath(PathStore.getCurrentPath()); - } - -}; - -module.exports = PathListener; diff --git a/modules/mixins/PathState.js b/modules/mixins/PathState.js new file mode 100644 index 0000000000..a06d735a2a --- /dev/null +++ b/modules/mixins/PathState.js @@ -0,0 +1,116 @@ +var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; +var HashLocation = require('../locations/HashLocation'); +var HistoryLocation = require('../locations/HistoryLocation'); +var RefreshLocation = require('../locations/RefreshLocation'); +var PathStore = require('../stores/PathStore'); +var supportsHistory = require('../utils/supportsHistory'); + +/** + * A hash of { name: location } pairs. + */ +var NAMED_LOCATIONS = { + hash: HashLocation, + history: HistoryLocation, + refresh: RefreshLocation +}; + +/** + * A mixin for components that need to know the current URL path. Components + * that use it may specify a `location` prop that they use to track changes + * to the URL. They also get: + * + * 1. An `updatePath` method that is called when the + * current URL path changes + * 2. A `getCurrentPath` method they can use to get + * the current URL path + * + * Example: + * + * var PathWatcher = React.createClass({ + * + * mixins: [ Router.PathState ], + * + * getInitialState: function () { + * return { + * currentPath: this.getCurrentPath() + * }; + * }, + * + * updatePath: function () { + * this.setState({ + * currentPath: this.getCurrentPath() + * }); + * } + * + * }); + */ +var PathState = { + + propTypes: { + + location: function (props, propName, componentName) { + var location = props[propName]; + + if (typeof location === 'string' && !(location in NAMED_LOCATIONS)) + return new Error('Unknown location "' + location + '", see ' + componentName); + } + + }, + + getDefaultProps: function () { + return { + location: canUseDOM ? HashLocation : null, + path: null + }; + }, + + /** + * Gets the location object this component uses to observe the URL. + */ + getLocation: function () { + var location = this.props.location; + + if (typeof location === 'string') + location = NAMED_LOCATIONS[location]; + + // Automatically fall back to full page refreshes in + // browsers that do not support HTML5 history. + if (location === HistoryLocation && !supportsHistory()) + location = RefreshLocation; + + return location; + }, + + componentWillMount: function () { + var location = this.getLocation(); + + if (location && location.setup) + location.setup(); + + if (this.updatePath) + this.updatePath(this.getCurrentPath(), this); + }, + + componentDidMount: function () { + PathStore.addChangeListener(this.handlePathChange); + }, + + componentWillUnmount: function () { + PathStore.removeChangeListener(this.handlePathChange); + }, + + handlePathChange: function (sender) { + if (this.isMounted() && this.updatePath) + this.updatePath(this.getCurrentPath(), sender); + }, + + /** + * Returns the current URL path. + */ + getCurrentPath: function () { + return PathStore.getCurrentPath(); + } + +}; + +module.exports = PathState; diff --git a/modules/mixins/RouteContainer.js b/modules/mixins/RouteContainer.js new file mode 100644 index 0000000000..6f2ee75638 --- /dev/null +++ b/modules/mixins/RouteContainer.js @@ -0,0 +1,164 @@ +var React = require('react'); +var invariant = require('react/lib/invariant'); +var Path = require('../utils/Path'); + +/** + * Performs some normalization and validation on a component and + * all of its children. + */ +function processRoute(route, container, namedRoutes) { + // Note: parentRoute may be a _or_ a . + var props = route.props; + + invariant( + React.isValidClass(props.handler), + 'The handler for the "%s" route must be a valid React class', + props.name || props.path + ); + + var parentPath = (container && container.props.path) || '/'; + + if ((props.path || props.name) && !props.isDefault && !props.catchAll) { + var path = props.path || props.name; + + // Relative paths extend their parent. + if (!Path.isAbsolute(path)) + path = Path.join(parentPath, path); + + props.path = Path.normalize(path); + } else { + props.path = parentPath; + + if (props.catchAll) + props.path += '*'; + } + + props.paramNames = Path.extractParamNames(props.path); + + // Make sure the route's path has all params its parent needs. + if (container && Array.isArray(container.props.paramNames)) { + container.props.paramNames.forEach(function (paramName) { + invariant( + props.paramNames.indexOf(paramName) !== -1, + 'The nested route path "%s" is missing the "%s" parameter of its parent path "%s"', + props.path, paramName, container.props.path + ); + }); + } + + // Make sure the route can be looked up by s. + if (props.name) { + var existingRoute = namedRoutes[props.name]; + + invariant( + !existingRoute || route === existingRoute, + 'You cannot use the name "%s" for more than one route', + props.name + ); + + namedRoutes[props.name] = route; + } + + // Handle . + if (props.catchAll) { + invariant( + container, + ' must have a parent ' + ); + + invariant( + container.props.notFoundRoute == null, + 'You may not have more than one per ' + ); + + container.props.notFoundRoute = route; + + return null; + } + + // Handle . + if (props.isDefault) { + invariant( + container, + ' must have a parent ' + ); + + invariant( + container.props.defaultRoute == null, + 'You may not have more than one per ' + ); + + container.props.defaultRoute = route; + + return null; + } + + // Make sure children is an array. + props.children = processRoutes(props.children, route, namedRoutes); + + return route; +} + +/** + * Processes many children s at once, always returning an array. + */ +function processRoutes(children, container, namedRoutes) { + var routes = []; + + React.Children.forEach(children, function (child) { + // Exclude s and s. + if (child = processRoute(child, container, namedRoutes)) + routes.push(child); + }); + + return routes; +} + +/** + * A mixin for components that have children. + */ +var RouteContainer = { + + childContextTypes: { + routeContainer: React.PropTypes.any.isRequired + }, + + getChildContext: function () { + return { + routeContainer: this + }; + }, + + getInitialState: function () { + var namedRoutes = {}; + + return { + namedRoutes: namedRoutes, + routes: processRoutes(this.props.children, this, namedRoutes) + }; + }, + + /** + * Returns an array of s in this container. + */ + getRoutes: function () { + return this.state.routes; + }, + + /** + * Returns a hash { name: route } of all named s in this container. + */ + getNamedRoutes: function () { + return this.state.namedRoutes; + }, + + /** + * Returns the with the given name, null if no such route exists. + */ + getRouteByName: function (routeName) { + return this.state.namedRoutes[routeName] || null; + } + +}; + +module.exports = RouteContainer; diff --git a/modules/mixins/RouteLookup.js b/modules/mixins/RouteLookup.js new file mode 100644 index 0000000000..00e29d5227 --- /dev/null +++ b/modules/mixins/RouteLookup.js @@ -0,0 +1,44 @@ +var React = require('react'); + +/** + * A mixin for components that need to lookup routes and/or + * build URL paths and links. + */ +var RouteLookup = { + + contextTypes: { + routeContainer: React.PropTypes.any.isRequired, + pathDelegate: React.PropTypes.any.isRequired + }, + + /** + * See RouteContainer#getRoutes. + */ + getRoutes: function () { + return this.context.routeContainer.getRoutes(); + }, + + /** + * See RouteContainer#getRouteByName. + */ + getRouteByName: function (routeName) { + return this.context.routeContainer.getRouteByName(routeName); + }, + + /** + * See PathDelegate#makePath. + */ + makePath: function (to, params, query) { + return this.context.pathDelegate.makePath(to, params, query); + }, + + /** + * See PathDelegate#makeHref. + */ + makeHref: function (to, params, query) { + return this.context.pathDelegate.makeHref(to, params, query); + } + +}; + +module.exports = RouteLookup; diff --git a/modules/mixins/ScrollDelegate.js b/modules/mixins/ScrollDelegate.js new file mode 100644 index 0000000000..d853f88eb8 --- /dev/null +++ b/modules/mixins/ScrollDelegate.js @@ -0,0 +1,39 @@ +var ScrollState = require('./ScrollState'); + +/** + * A mixin for components that manage the window's scroll position. + */ +var ScrollDelegate = { + + mixins: [ ScrollState ], + + componentWillMount: function () { + if (this.getScrollBehavior()) + this._scrollPositions = {}; + }, + + /** + * Records the current scroll position for the given path. + */ + recordScroll: function (path) { + if (this._scrollPositions) + this._scrollPositions[path] = this.getCurrentScrollPosition(); + }, + + /** + * Updates the current scroll position according to the last + * one that was recorded for the given path. + */ + updateScroll: function (path, sender) { + if (this._scrollPositions) { + var behavior = this.getScrollBehavior(); + var position = this._scrollPositions[path]; + + if (behavior && position) + behavior.updateScrollPosition(position, sender); + } + } + +}; + +module.exports = ScrollDelegate; diff --git a/modules/mixins/ScrollState.js b/modules/mixins/ScrollState.js new file mode 100644 index 0000000000..623c0154dc --- /dev/null +++ b/modules/mixins/ScrollState.js @@ -0,0 +1,103 @@ +var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; +var invariant = require('react/lib/invariant'); + +/** + * A scroll behavior that attempts to imitate the default behavior + * of modern browsers. + */ +var ImitateBrowserBehavior = { + + updateScrollPosition: function (position, sender) { + if (sender === window) { + window.scrollTo(position.x, position.y); + } else { + // Clicking on links always scrolls the window to the top. + window.scrollTo(0, 0); + } + } + +}; + +/** + * A scroll behavior that always scrolls to the top of the page + * after a transition. + */ +var ScrollToTopBehavior = { + + updateScrollPosition: function () { + window.scrollTo(0, 0); + } + +}; + +/** + * A hash of { name: scrollBehavior } pairs. + */ +var NAMED_SCROLL_BEHAVIORS = { + none: null, + imitateBrowser: ImitateBrowserBehavior, + scrollToTop: ScrollToTopBehavior +}; + +/** + * A mixin for components that need to know the current scroll position. + */ +var ScrollState = { + + propTypes: { + + scrollBehavior: function (props, propName, componentName) { + var behavior = props[propName]; + + if (typeof behavior === 'string' && !(behavior in NAMED_SCROLL_BEHAVIORS)) + return new Error('Unknown scroll behavior "' + behavior + '", see ' + componentName); + } + + }, + + getDefaultProps: function () { + return { + scrollBehavior: canUseDOM ? ImitateBrowserBehavior : null + }; + }, + + /** + * Gets the scroll behavior object this component uses to observe + * the current scroll position. + */ + getScrollBehavior: function () { + var behavior = this.props.scrollBehavior; + + if (typeof behavior === 'string') + behavior = NAMED_SCROLL_STRATEGIES[behavior]; + + return behavior; + }, + + componentWillMount: function () { + var behavior = this.getScrollBehavior(); + + invariant( + behavior == null || canUseDOM, + 'Cannot use scroll behavior without a DOM' + ); + }, + + /** + * Returns the current scroll position as { x, y }. + */ + getCurrentScrollPosition: function () { + invariant( + canUseDOM, + 'Cannot get current scroll position without a DOM' + ); + + return { + x: window.scrollX, + y: window.scrollY + }; + } + +}; + +module.exports = ScrollState; diff --git a/modules/mixins/TransitionHandler.js b/modules/mixins/TransitionHandler.js new file mode 100644 index 0000000000..4514d9cc5e --- /dev/null +++ b/modules/mixins/TransitionHandler.js @@ -0,0 +1,418 @@ +var React = require('react'); +var warning = require('react/lib/warning'); +var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; +var copyProperties = require('react/lib/copyProperties'); +var Route = require('../components/Route'); +var ActiveDelegate = require('./ActiveDelegate'); +var PathDelegate = require('./PathDelegate'); +var ScrollDelegate = require('./ScrollDelegate'); +var reversedArray = require('../utils/reversedArray'); +var Transition = require('../utils/Transition'); +var Redirect = require('../utils/Redirect'); +var Path = require('../utils/Path'); + +function makeMatch(route, params) { + return { route: route, params: params }; +} + +function getRootMatch(matches) { + return matches[matches.length - 1]; +} + +function findMatches(path, routes, defaultRoute, notFoundRoute) { + var matches = null, route, params; + + for (var i = 0, len = routes.length; i < len; ++i) { + route = routes[i]; + + // Check the subtree first to find the most deeply-nested match. + matches = findMatches(path, route.props.children, route.props.defaultRoute, route.props.notFoundRoute); + + if (matches != null) { + var rootParams = getRootMatch(matches).params; + + params = route.props.paramNames.reduce(function (params, paramName) { + params[paramName] = rootParams[paramName]; + return params; + }, {}); + + matches.unshift(makeMatch(route, params)); + + return matches; + } + + // No routes in the subtree matched, so check this route. + params = Path.extractParams(route.props.path, path); + + if (params) + return [ makeMatch(route, params) ]; + } + + // No routes matched, so try the default route if there is one. + if (defaultRoute && (params = Path.extractParams(defaultRoute.props.path, path))) + return [ makeMatch(defaultRoute, params) ]; + + // Last attempt: does the "not found" route match? + if (notFoundRoute && (params = Path.extractParams(notFoundRoute.props.path, path))) + return [ makeMatch(notFoundRoute, params) ]; + + return matches; +} + +function hasMatch(matches, match) { + return matches.some(function (m) { + if (m.route !== match.route) + return false; + + for (var property in m.params) + if (m.params[property] !== match.params[property]) + return false; + + return true; + }); +} + +function updateMatchComponents(matches, refs) { + var i = 0, component; + while (component = refs.__activeRoute__) { + matches[i++].component = component; + refs = component.refs; + } +} + +/** + * Computes the next state for the given component and calls + * callback(error, nextState) when finished. Also runs all + * transition hooks along the way. + */ +function computeNextState(component, transition, callback) { + if (component.state.path === transition.path) + return callback(); // Nothing to do! + + var currentMatches = component.state.matches; + var nextMatches = component.match(transition.path); + + warning( + nextMatches, + 'No route matches path "' + transition.path + '". Make sure you have ' + + ' somewhere in your routes' + ); + + if (!nextMatches) + nextMatches = []; + + var fromMatches, toMatches; + if (currentMatches.length) { + updateMatchComponents(currentMatches, component.refs); + + fromMatches = currentMatches.filter(function (match) { + return !hasMatch(nextMatches, match); + }); + + toMatches = nextMatches.filter(function (match) { + return !hasMatch(currentMatches, match); + }); + } else { + fromMatches = []; + toMatches = nextMatches; + } + + var query = Path.extractQuery(transition.path) || {}; + + runTransitionFromHooks(fromMatches, transition, function (error) { + if (error || transition.isAborted) + return callback(error); + + runTransitionToHooks(toMatches, transition, query, function (error) { + if (error || transition.isAborted) + return callback(error); + + var matches = currentMatches.slice(0, -fromMatches.length).concat(toMatches); + var rootMatch = getRootMatch(matches); + var params = (rootMatch && rootMatch.params) || {}; + var routes = matches.map(function (match) { + return match.route; + }); + + callback(null, { + path: transition.path, + matches: matches, + activeRoutes: routes, + activeParams: params, + activeQuery: query + }); + }); + }); +} + +/** + * Calls the willTransitionFrom hook of all handlers in the given matches + * serially in reverse with the transition object and the current instance of + * the route's handler, so that the deepest nested handlers are called first. + * Calls callback(error) when finished. + */ +function runTransitionFromHooks(matches, transition, callback) { + var hooks = reversedArray(matches).map(function (match) { + return function () { + var handler = match.route.props.handler; + + if (!transition.isAborted && handler.willTransitionFrom) + return handler.willTransitionFrom(transition, match.component); + + var promise = transition.promise; + delete transition.promise; + + return promise; + }; + }); + + runHooks(hooks, callback); +} + +/** + * Calls the willTransitionTo hook of all handlers in the given matches + * serially with the transition object and any params that apply to that + * handler. Calls callback(error) when finished. + */ +function runTransitionToHooks(matches, transition, query, callback) { + var hooks = matches.map(function (match) { + return function () { + var handler = match.route.props.handler; + + if (!transition.isAborted && handler.willTransitionTo) + handler.willTransitionTo(transition, match.params, query); + + var promise = transition.promise; + delete transition.promise; + + return promise; + }; + }); + + runHooks(hooks, callback); +} + +/** + * Runs all hook functions serially and calls callback(error) when finished. + * A hook may return a promise if it needs to execute asynchronously. + */ +function runHooks(hooks, callback) { + try { + var promise = hooks.reduce(function (promise, hook) { + // The first hook to use transition.wait makes the rest + // of the transition async from that point forward. + return promise ? promise.then(hook) : hook(); + }, null); + } catch (error) { + return callback(error); // Sync error. + } + + if (promise) { + // Use setTimeout to break the promise chain. + promise.then(function () { + setTimeout(callback); + }, function (error) { + setTimeout(function () { + callback(error); + }); + }); + } else { + callback(); + } +} + +function returnNull() { + return null; +} + +function computeHandlerProps(matches, query) { + var handler = returnNull; + var props = { + ref: null, + params: null, + query: null, + activeRouteHandler: handler, + key: null + }; + + reversedArray(matches).forEach(function (match) { + var route = match.route; + + props = Route.getUnreservedProps(route.props); + + props.ref = '__activeRoute__'; + props.params = match.params; + props.query = query; + props.activeRouteHandler = handler; + + // TODO: Can we remove addHandlerKey? + if (route.props.addHandlerKey) + props.key = Path.injectParams(route.props.path, match.params); + + handler = function (props, addedProps) { + if (arguments.length > 2 && typeof arguments[2] !== 'undefined') + throw new Error('Passing children to a route handler is not supported'); + + return route.props.handler( + copyProperties(props, addedProps) + ); + }.bind(this, props); + }); + + return props; +} + +var BrowserTransitionHandling = { + + /** + * Handles errors that were thrown asynchronously while transitioning. The + * default behavior is to re-throw the error so it isn't swallowed silently. + */ + handleTransitionError: function (error) { + throw error; // This error probably originated in a transition hook. + }, + + /** + * Handles aborted transitions. + */ + handleAbortedTransition: function (transition) { + var reason = transition.abortReason; + + if (reason instanceof Redirect) { + this.replaceWith(reason.to, reason.params, reason.query); + } else { + this.goBack(); + } + } + +}; + +var ServerTransitionHandling = { + + handleTransitionError: function (error) { + // TODO + }, + + handleAbortedTransition: function (transition) { + var reason = transition.abortReason; + + if (reason instanceof Redirect) { + // TODO + } else { + // TODO + } + } + +}; + +var TransitionHandling = canUseDOM ? BrowserTransitionHandling : ServerTransitionHandling; + +/** + * A mixin for components that handle transitions. + */ +var TransitionHandler = { + + mixins: [ ActiveDelegate, PathDelegate, ScrollDelegate ], + + propTypes: { + onTransitionError: React.PropTypes.func.isRequired, + onAbortedTransition: React.PropTypes.func.isRequired + }, + + getDefaultProps: function () { + return { + onTransitionError: TransitionHandling.handleTransitionError, + onAbortedTransition: TransitionHandling.handleAbortedTransition + }; + }, + + getInitialState: function () { + return { + matches: [] + }; + }, + + /** + * See PathState. + */ + updatePath: function (path, sender) { + if (this.state.path === path) + return; // Nothing to do! + + if (this.state.path) + this.recordScroll(this.state.path); + + var self = this; + + this.dispatch(path, function (error, transition) { + if (error) { + self.props.onTransitionError.call(self, error); + } else if (transition.isAborted) { + self.props.onAbortedTransition.call(self, transition); + } else { + self.emitChange(); + self.updateScroll(path, sender); + } + }); + }, + + /** + * Performs a depth-first search for the first route in the tree that matches on + * the given path. Returns an array of all routes in the tree leading to the one + * that matched in the format { route, params } where params is an object that + * contains the URL parameters relevant to that route. Returns null if no route + * in the tree matches the path. + * + * React.renderComponent( + * + * + * + * + * + * + * ).match('/posts/123'); => [ { route: , params: {} }, + * { route: , params: { id: '123' } } ] + */ + match: function (path) { + return findMatches(Path.withoutQuery(path), this.getRoutes(), this.props.defaultRoute, this.props.notFoundRoute); + }, + + /** + * Performs a transition to the given path and calls callback(error, transition) + * with the Transition object when the transition is finished and the component's + * state has been updated accordingly. + * + * In a transition, the router first determines which routes are involved by + * beginning with the current route, up the route tree to the first parent route + * that is shared with the destination route, and back down the tree to the + * destination route. The willTransitionFrom hook is invoked on all route handlers + * we're transitioning away from, in reverse nesting order. Likewise, the + * willTransitionTo hook is invoked on all route handlers we're transitioning to. + * + * Both willTransitionFrom and willTransitionTo hooks may either abort or redirect + * the transition. To resolve asynchronously, they may use transition.wait(promise). + */ + dispatch: function (path, callback) { + var transition = new Transition(this, path); + var self = this; + + computeNextState(this, transition, function (error, nextState) { + if (error || nextState == null) + return callback(error, transition); + + self.setState(nextState, function () { + callback(null, transition); + }); + }); + }, + + /** + * Returns the props that should be used for the top-level route handler. + */ + getHandlerProps: function () { + return computeHandlerProps(this.state.matches, this.state.activeQuery); + } + +}; + +module.exports = TransitionHandler; diff --git a/modules/mixins/Transitions.js b/modules/mixins/Transitions.js new file mode 100644 index 0000000000..0018b3e9a5 --- /dev/null +++ b/modules/mixins/Transitions.js @@ -0,0 +1,35 @@ +var React = require('react'); + +/** + * A mixin for components that need to initiate transitions to other routes. + */ +var Transitions = { + + contextTypes: { + pathDelegate: React.PropTypes.any.isRequired + }, + + /** + * See PathDelegate#transitionTo. + */ + transitionTo: function (to, params, query) { + return this.context.pathDelegate.transitionTo(to, params, query, this); + }, + + /** + * See PathDelegate#replaceWith. + */ + replaceWith: function (to, params, query) { + return this.context.pathDelegate.replaceWith(to, params, query, this); + }, + + /** + * See PathDelegate#goBack. + */ + goBack: function () { + return this.context.pathDelegate.goBack(this); + } + +}; + +module.exports = Transitions; diff --git a/modules/stores/PathStore.js b/modules/stores/PathStore.js index ff73858a7e..c5f502a9bb 100644 --- a/modules/stores/PathStore.js +++ b/modules/stores/PathStore.js @@ -1,129 +1,46 @@ -var warning = require('react/lib/warning'); var EventEmitter = require('events').EventEmitter; var LocationActions = require('../actions/LocationActions'); var LocationDispatcher = require('../dispatchers/LocationDispatcher'); -var supportsHistory = require('../utils/supportsHistory'); -var HistoryLocation = require('../locations/HistoryLocation'); -var RefreshLocation = require('../locations/RefreshLocation'); var CHANGE_EVENT = 'change'; var _events = new EventEmitter; -function notifyChange() { - _events.emit(CHANGE_EVENT); +function notifyChange(sender) { + _events.emit(CHANGE_EVENT, sender); } -var _scrollPositions = {}; - -function recordScrollPosition(path) { - _scrollPositions[path] = { - x: window.scrollX, - y: window.scrollY - }; -} - -function updateScrollPosition(path) { - var p = PathStore.getScrollPosition(path); - window.scrollTo(p.x, p.y); -} - -var _location; +var _currentPath; /** - * The PathStore keeps track of the current URL path and manages - * the location strategy that is used to update the URL. + * The PathStore keeps track of the current URL path. */ var PathStore = { addChangeListener: function (listener) { - _events.on(CHANGE_EVENT, listener); + _events.addListener(CHANGE_EVENT, listener); }, removeChangeListener: function (listener) { _events.removeListener(CHANGE_EVENT, listener); - - // Automatically teardown when the last listener is removed. - if (EventEmitter.listenerCount(_events, CHANGE_EVENT) === 0) - PathStore.teardown(); - }, - - setup: function (location) { - // When using HistoryLocation, automatically fallback - // to RefreshLocation in browsers that do not support - // the HTML5 history API. - if (location === HistoryLocation && !supportsHistory()) - location = RefreshLocation; - - if (_location == null) { - _location = location; - - if (_location && typeof _location.setup === 'function') - _location.setup(notifyChange); - } else { - warning( - _location === location, - 'Cannot use location %s, already using %s', location, _location - ); - } - }, - - teardown: function () { - _events.removeAllListeners(CHANGE_EVENT); - - if (_location && typeof _location.teardown === 'function') - _location.teardown(); - - _location = null; - }, - - /** - * Returns the location object currently in use. - */ - getLocation: function () { - return _location; }, /** * Returns the current URL path. */ getCurrentPath: function () { - return _location.getCurrentPath(); - }, - - /** - * Returns the last known scroll position for the given path. - */ - getScrollPosition: function (path) { - return _scrollPositions[path] || { x: 0, y: 0 }; + return _currentPath; }, dispatchToken: LocationDispatcher.register(function (payload) { var action = payload.action; - var currentPath = _location.getCurrentPath(); switch (action.type) { + case LocationActions.SETUP: case LocationActions.PUSH: - if (currentPath !== action.path) { - recordScrollPosition(currentPath); - _location.push(action.path); - } - break; - case LocationActions.REPLACE: - if (currentPath !== action.path) { - recordScrollPosition(currentPath); - _location.replace(action.path); - } - break; - case LocationActions.POP: - recordScrollPosition(currentPath); - _location.pop(); - break; - - case LocationActions.UPDATE_SCROLL: - updateScrollPosition(currentPath); - break; + _currentPath = action.path; + notifyChange(action.sender); } }) diff --git a/modules/stores/RouteStore.js b/modules/stores/RouteStore.js deleted file mode 100644 index a287f393e6..0000000000 --- a/modules/stores/RouteStore.js +++ /dev/null @@ -1,156 +0,0 @@ -var React = require('react'); -var invariant = require('react/lib/invariant'); -var warning = require('react/lib/warning'); -var Path = require('../utils/Path'); - -var _namedRoutes = {}; - -/** - * The RouteStore contains a directory of all s in the system. It is - * used primarily for looking up routes by name so that s can use a - * route name in the "to" prop and users can use route names in `Router.transitionTo` - * and other high-level utility methods. - */ -var RouteStore = { - - /** - * Removes all references to s from the store. Should only ever - * really be used in tests to clear the store between test runs. - */ - unregisterAllRoutes: function () { - _namedRoutes = {}; - }, - - /** - * Removes the reference to the given and all of its children - * from the store. - */ - unregisterRoute: function (route) { - var props = route.props; - - if (props.name) - delete _namedRoutes[props.name]; - - React.Children.forEach(props.children, RouteStore.unregisterRoute); - }, - - /** - * Registers a and all of its children with the store. Also, - * does some normalization and validation on route props. - */ - registerRoute: function (route, parentRoute) { - // Note: parentRoute may be a _or_ a . - var props = route.props; - - invariant( - React.isValidClass(props.handler), - 'The handler for the "%s" route must be a valid React class', - props.name || props.path - ); - - var parentPath = (parentRoute && parentRoute.props.path) || '/'; - - if ((props.path || props.name) && !props.isDefault && !props.catchAll) { - var path = props.path || props.name; - - // Relative paths extend their parent. - if (!Path.isAbsolute(path)) - path = Path.join(parentPath, path); - - props.path = Path.normalize(path); - } else { - props.path = parentPath; - - if (props.catchAll) - props.path += '*'; - } - - props.paramNames = Path.extractParamNames(props.path); - - // Make sure the route's path has all params its parent needs. - if (parentRoute && Array.isArray(parentRoute.props.paramNames)) { - parentRoute.props.paramNames.forEach(function (paramName) { - invariant( - props.paramNames.indexOf(paramName) !== -1, - 'The nested route path "%s" is missing the "%s" parameter of its parent path "%s"', - props.path, paramName, parentRoute.props.path - ); - }); - } - - // Make sure the route can be looked up by s. - if (props.name) { - var existingRoute = _namedRoutes[props.name]; - - invariant( - !existingRoute || route === existingRoute, - 'You cannot use the name "%s" for more than one route', - props.name - ); - - _namedRoutes[props.name] = route; - } - - if (props.catchAll) { - invariant( - parentRoute, - ' must have a parent ' - ); - - invariant( - parentRoute.props.notFoundRoute == null, - 'You may not have more than one per ' - ); - - parentRoute.props.notFoundRoute = route; - - return null; - } - - if (props.isDefault) { - invariant( - parentRoute, - ' must have a parent ' - ); - - invariant( - parentRoute.props.defaultRoute == null, - 'You may not have more than one per ' - ); - - parentRoute.props.defaultRoute = route; - - return null; - } - - // Make sure children is an array. - props.children = RouteStore.registerChildren(props.children, route); - - return route; - }, - - /** - * Registers many children routes at once, always returning an array. - */ - registerChildren: function (children, parentRoute) { - var routes = []; - - React.Children.forEach(children, function (child) { - // Exclude s. - if (child = RouteStore.registerRoute(child, parentRoute)) - routes.push(child); - }); - - return routes; - }, - - /** - * Returns the Route object with the given name, if one exists. - */ - getRouteByName: function (routeName) { - return _namedRoutes[routeName] || null; - } - -}; - -module.exports = RouteStore; diff --git a/modules/utils/Transition.js b/modules/utils/Transition.js index 77f0c21369..354ef611c1 100644 --- a/modules/utils/Transition.js +++ b/modules/utils/Transition.js @@ -1,7 +1,6 @@ var mixInto = require('react/lib/mixInto'); var Promise = require('./Promise'); var Redirect = require('./Redirect'); -var replaceWith = require('../actions/LocationActions').replaceWith; /** * Encapsulates a transition to a given path. @@ -9,7 +8,8 @@ var replaceWith = require('../actions/LocationActions').replaceWith; * The willTransitionTo and willTransitionFrom handlers receive * an instance of this class as their first argument. */ -function Transition(path) { +function Transition(pathDelegate, path) { + this.pathDelegate = pathDelegate; this.path = path; this.abortReason = null; this.isAborted = false; @@ -31,7 +31,7 @@ mixInto(Transition, { }, retry: function () { - replaceWith(this.path); + this.pathDelegate.replaceWith(this.path); } }); diff --git a/modules/utils/getWindowPath.js b/modules/utils/getWindowPath.js index 108c2285b2..2a2dd065ff 100644 --- a/modules/utils/getWindowPath.js +++ b/modules/utils/getWindowPath.js @@ -6,4 +6,3 @@ function getWindowPath() { } module.exports = getWindowPath; - diff --git a/modules/utils/isAbsoluteURL.js b/modules/utils/isAbsoluteURL.js deleted file mode 100644 index bdcd806583..0000000000 --- a/modules/utils/isAbsoluteURL.js +++ /dev/null @@ -1,11 +0,0 @@ -var ABSOLUTE_URL_FORMAT = /^https?:\/\//; - -/** - * Returns true if the given string contains an absolute URL - * according to http://tools.ietf.org/html/rfc3986#page-27. - */ -function isAbsoluteURL(string) { - return typeof string === 'string' && ABSOLUTE_URL_FORMAT.test(string); -} - -module.exports = isAbsoluteURL; diff --git a/modules/utils/makeHref.js b/modules/utils/makeHref.js deleted file mode 100644 index 919d65a76c..0000000000 --- a/modules/utils/makeHref.js +++ /dev/null @@ -1,18 +0,0 @@ -var HashLocation = require('../locations/HashLocation'); -var PathStore = require('../stores/PathStore'); -var makePath = require('./makePath'); - -/** - * Returns a string that may safely be used as the href of a - * link to the route with the given name. - */ -function makeHref(to, params, query) { - var path = makePath(to, params, query); - - if (PathStore.getLocation() === HashLocation) - return '#' + path; - - return path; -} - -module.exports = makeHref; diff --git a/modules/utils/makePath.js b/modules/utils/makePath.js deleted file mode 100644 index dd7785e0cf..0000000000 --- a/modules/utils/makePath.js +++ /dev/null @@ -1,28 +0,0 @@ -var invariant = require('react/lib/invariant'); -var RouteStore = require('../stores/RouteStore'); -var Path = require('./Path'); - -/** - * Returns an absolute URL path created from the given route name, URL - * parameters, and query values. - */ -function makePath(to, params, query) { - var path; - if (Path.isAbsolute(to)) { - path = Path.normalize(to); - } else { - var route = RouteStore.getRouteByName(to); - - invariant( - route, - 'Unable to find a route named "' + to + '". Make sure you have ' + - 'a defined somewhere in your routes' - ); - - path = route.props.path; - } - - return Path.withQuery(Path.injectParams(path, params), query); -} - -module.exports = makePath; diff --git a/modules/utils/reversedArray.js b/modules/utils/reversedArray.js new file mode 100644 index 0000000000..5433b6c69e --- /dev/null +++ b/modules/utils/reversedArray.js @@ -0,0 +1,5 @@ +function reversedArray(array) { + return array.slice(0).reverse(); +} + +module.exports = reversedArray; From 637c0ac4d2846d703e2e5a1e29bd6b552a79d561 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Mon, 29 Sep 2014 09:13:08 -0700 Subject: [PATCH 02/14] [added] --- examples/data-flow/app.js | 5 ++- examples/master-detail/app.js | 12 +++-- examples/transitions/app.js | 5 ++- modules/locations/HashLocation.js | 42 ++++++++--------- modules/locations/HistoryLocation.js | 31 +++++-------- modules/locations/MemoryLocation.js | 67 ---------------------------- modules/locations/RefreshLocation.js | 3 +- modules/mixins/PathDelegate.js | 29 ++++++------ modules/mixins/PathState.js | 49 ++++++++++---------- modules/mixins/RouteLookup.js | 7 +++ modules/mixins/ScrollDelegate.js | 4 +- modules/mixins/ScrollState.js | 20 +++++---- modules/mixins/TransitionHandler.js | 4 +- modules/stores/PathStore.js | 16 +++++-- 14 files changed, 125 insertions(+), 169 deletions(-) delete mode 100644 modules/locations/MemoryLocation.js diff --git a/examples/data-flow/app.js b/examples/data-flow/app.js index 8434f1b8d7..272dfa6c4c 100644 --- a/examples/data-flow/app.js +++ b/examples/data-flow/app.js @@ -6,6 +6,9 @@ var Routes = Router.Routes; var Link = Router.Link; var App = React.createClass({ + + mixins: [ Router.Transitions ], + getInitialState: function() { return { tacos: [ @@ -28,7 +31,7 @@ var App = React.createClass({ return taco.name != removedTaco; }); this.setState({tacos: tacos}); - Router.transitionTo('/'); + this.transitionTo('/'); }, render: function() { diff --git a/examples/master-detail/app.js b/examples/master-detail/app.js index 2b32092eca..653f76f0e7 100644 --- a/examples/master-detail/app.js +++ b/examples/master-detail/app.js @@ -132,6 +132,9 @@ var Index = React.createClass({ }); var Contact = React.createClass({ + + mixins: [ Router.Transitions ], + getStateFromStore: function(props) { props = props || this.props; return { @@ -164,7 +167,7 @@ var Contact = React.createClass({ destroy: function() { ContactStore.removeContact(this.props.params.id); - Router.transitionTo('/'); + this.transitionTo('/'); }, render: function() { @@ -182,14 +185,17 @@ var Contact = React.createClass({ }); var NewContact = React.createClass({ + + mixins: [ Router.Transitions ], + createContact: function(event) { event.preventDefault(); ContactStore.addContact({ first: this.refs.first.getDOMNode().value, last: this.refs.last.getDOMNode().value }, function(contact) { - Router.transitionTo('contact', { id: contact.id }); - }); + this.transitionTo('contact', { id: contact.id }); + }.bind(this)); }, render: function() { diff --git a/examples/transitions/app.js b/examples/transitions/app.js index d40e87e716..145136f61a 100644 --- a/examples/transitions/app.js +++ b/examples/transitions/app.js @@ -26,6 +26,9 @@ var Dashboard = React.createClass({ }); var Form = React.createClass({ + + mixins: [ Router.Transitions ], + statics: { willTransitionFrom: function(transition, component) { if (component.refs.userInput.getDOMNode().value !== '') { @@ -39,7 +42,7 @@ var Form = React.createClass({ handleSubmit: function(event) { event.preventDefault(); this.refs.userInput.getDOMNode().value = ''; - Router.transitionTo('/'); + this.transitionTo('/'); }, render: function() { diff --git a/modules/locations/HashLocation.js b/modules/locations/HashLocation.js index f6dc5a3d10..6e3876fa91 100644 --- a/modules/locations/HashLocation.js +++ b/modules/locations/HashLocation.js @@ -5,31 +5,35 @@ var LocationDispatcher = require('../dispatchers/LocationDispatcher'); var getWindowPath = require('../utils/getWindowPath'); function getHashPath() { - return window.location.hash.substr(1) || '/'; + return window.location.hash.substr(1); } +var _actionType; + function ensureSlash() { var path = getHashPath(); if (path.charAt(0) === '/') return true; - HashLocation.replace('/' + path, _actionSender); + HashLocation.replace('/' + path); return false; } -var _actionType, _actionSender; - function onHashChange() { if (ensureSlash()) { + var path = getHashPath(); + LocationDispatcher.handleViewAction({ - type: _actionType, - path: getHashPath(), - sender: _actionSender || window + // If we don't have an _actionType then all we know is the hash + // changed. It was probably caused by the user clicking the Back + // button, but may have also been the Forward button. + type: _actionType || LocationActions.POP, + path: getHashPath() }); - _actionSender = null; + _actionType = null; } } @@ -49,18 +53,19 @@ var HashLocation = { 'You cannot use HashLocation in an environment with no DOM' ); + ensureSlash(); + + LocationDispatcher.handleViewAction({ + type: LocationActions.SETUP, + path: getHashPath() + }); + if (window.addEventListener) { window.addEventListener('hashchange', onHashChange, false); } else { window.attachEvent('onhashchange', onHashChange); } - LocationDispatcher.handleViewAction({ - type: LocationActions.SETUP, - path: getHashPath(), - sender: window - }); - _isSetup = true; }, @@ -74,21 +79,18 @@ var HashLocation = { _isSetup = false; }, - push: function (path, sender) { + push: function (path) { _actionType = LocationActions.PUSH; - _actionSender = sender; window.location.hash = path; }, - replace: function (path, sender) { + replace: function (path) { _actionType = LocationActions.REPLACE; - _actionSender = sender; window.location.replace(getWindowPath() + '#' + path); }, - pop: function (sender) { + pop: function () { _actionType = LocationActions.POP; - _actionSender = sender; window.history.back(); }, diff --git a/modules/locations/HistoryLocation.js b/modules/locations/HistoryLocation.js index 90cdd9b0e1..7e4b5fbe0a 100644 --- a/modules/locations/HistoryLocation.js +++ b/modules/locations/HistoryLocation.js @@ -4,16 +4,11 @@ var LocationActions = require('../actions/LocationActions'); var LocationDispatcher = require('../dispatchers/LocationDispatcher'); var getWindowPath = require('../utils/getWindowPath'); -var _actionSender; - function onPopState() { LocationDispatcher.handleViewAction({ type: LocationActions.POP, - path: getWindowPath(), - sender: _actionSender || window + path: getWindowPath() }); - - _actionSender = null; } var _isSetup = false; @@ -32,18 +27,17 @@ var HistoryLocation = { 'You cannot use HistoryLocation in an environment with no DOM' ); + LocationDispatcher.handleViewAction({ + type: LocationActions.SETUP, + path: getWindowPath() + }); + if (window.addEventListener) { window.addEventListener('popstate', onPopState, false); } else { window.attachEvent('popstate', onPopState); } - LocationDispatcher.handleViewAction({ - type: LocationActions.SETUP, - path: getWindowPath(), - sender: window - }); - _isSetup = true; }, @@ -57,28 +51,25 @@ var HistoryLocation = { _isSetup = false; }, - push: function (path, sender) { + push: function (path) { window.history.pushState({ path: path }, '', path); LocationDispatcher.handleViewAction({ type: LocationActions.PUSH, - path: getWindowPath(), - sender: sender + path: getWindowPath() }); }, - replace: function (path, sender) { + replace: function (path) { window.history.replaceState({ path: path }, '', path); LocationDispatcher.handleViewAction({ type: LocationActions.REPLACE, - path: getWindowPath(), - sender: sender + path: getWindowPath() }); }, - pop: function (sender) { - _actionSender = sender; + pop: function () { window.history.back(); }, diff --git a/modules/locations/MemoryLocation.js b/modules/locations/MemoryLocation.js deleted file mode 100644 index 9908adc600..0000000000 --- a/modules/locations/MemoryLocation.js +++ /dev/null @@ -1,67 +0,0 @@ -var warning = require('react/lib/warning'); -var LocationActions = require('../actions/LocationActions'); -var LocationDispatcher = require('../dispatchers/LocationDispatcher'); - -var _lastPath = null; -var _currentPath = null; - -function getCurrentPath() { - return _currentPath || '/'; -} - -/** - * A Location that does not require a DOM. - */ -var MemoryLocation = { - - setup: function () { - LocationDispatcher.handleViewAction({ - type: LocationActions.SETUP, - path: getCurrentPath() - }); - }, - - push: function (path, sender) { - _lastPath = _currentPath; - _currentPath = path; - - LocationDispatcher.handleViewAction({ - type: LocationActions.PUSH, - path: getCurrentPath(), - sender: sender - }); - }, - - replace: function (path, sender) { - _currentPath = path; - - LocationDispatcher.handleViewAction({ - type: LocationActions.REPLACE, - path: getCurrentPath(), - sender: sender - }); - }, - - pop: function (sender) { - warning( - _lastPath != null, - 'You cannot use MemoryLocation to go back more than once' - ); - - _currentPath = _lastPath; - _lastPath = null; - - LocationDispatcher.handleViewAction({ - type: LocationActions.POP, - path: getCurrentPath(), - sender: sender - }); - }, - - toString: function () { - return ''; - } - -}; - -module.exports = MemoryLocation; diff --git a/modules/locations/RefreshLocation.js b/modules/locations/RefreshLocation.js index c930c6c08d..6e806639c0 100644 --- a/modules/locations/RefreshLocation.js +++ b/modules/locations/RefreshLocation.js @@ -19,8 +19,7 @@ var RefreshLocation = { LocationDispatcher.handleViewAction({ type: LocationActions.SETUP, - path: getWindowPath(), - sender: window + path: getWindowPath() }); }, diff --git a/modules/mixins/PathDelegate.js b/modules/mixins/PathDelegate.js index 0e31287563..e9c99b7795 100644 --- a/modules/mixins/PathDelegate.js +++ b/modules/mixins/PathDelegate.js @@ -2,6 +2,7 @@ var React = require('react'); var invariant = require('react/lib/invariant'); var PathState = require('./PathState'); var RouteContainer = require('./RouteContainer'); +var LocationActions = require('../actions/LocationActions'); var HashLocation = require('../locations/HashLocation'); var Path = require('../utils/Path'); @@ -62,17 +63,16 @@ var PathDelegate = { * Transitions to the URL specified in the arguments by pushing * a new URL onto the history stack. */ - transitionTo: function (to, params, query, sender) { - sender = sender || this; - + transitionTo: function (to, params, query) { var path = this.makePath(to, params, query); var location = this.getLocation(); - // If we have a location, route the transition through it. + // If we have a location, route the transition + // through it so the URL is updated as well. if (location) { - location.push(path, this); + location.push(path); } else if (this.updatePath) { - this.updatePath(path, this); + this.updatePath(path, LocationActions.PUSH); } }, @@ -80,26 +80,23 @@ var PathDelegate = { * Transitions to the URL specified in the arguments by replacing * the current URL in the history stack. */ - replaceWith: function (to, params, query, sender) { - sender = sender || this; - + replaceWith: function (to, params, query) { var path = this.makePath(to, params, query); var location = this.getLocation(); - // If we have a location, route the transition through it. + // If we have a location, route the transition + // through it so the URL is updated as well. if (location) { - location.replace(path, sender); + location.replace(path); } else if (this.updatePath) { - this.updatePath(path, sender); + this.updatePath(path, LocationActions.REPLACE); } }, /** * Transitions to the previous URL. */ - goBack: function (sender) { - sender = sender || this; - + goBack: function () { var location = this.getLocation(); invariant( @@ -107,7 +104,7 @@ var PathDelegate = { 'You cannot goBack without a location' ); - location.pop(sender); + location.pop(); } }; diff --git a/modules/mixins/PathState.js b/modules/mixins/PathState.js index a06d735a2a..78f7a88318 100644 --- a/modules/mixins/PathState.js +++ b/modules/mixins/PathState.js @@ -1,9 +1,11 @@ +var React = require('react'); +var invariant = require('react/lib/invariant'); var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; var HashLocation = require('../locations/HashLocation'); var HistoryLocation = require('../locations/HistoryLocation'); var RefreshLocation = require('../locations/RefreshLocation'); -var PathStore = require('../stores/PathStore'); var supportsHistory = require('../utils/supportsHistory'); +var PathStore = require('../stores/PathStore'); /** * A hash of { name: location } pairs. @@ -16,13 +18,12 @@ var NAMED_LOCATIONS = { /** * A mixin for components that need to know the current URL path. Components - * that use it may specify a `location` prop that they use to track changes - * to the URL. They also get: + * that use it get two things: * - * 1. An `updatePath` method that is called when the + * 1. An optional `location` prop that they use to track + * changes to the URL + * 2. An `updatePath` method that is called when the * current URL path changes - * 2. A `getCurrentPath` method they can use to get - * the current URL path * * Example: * @@ -30,15 +31,9 @@ var NAMED_LOCATIONS = { * * mixins: [ Router.PathState ], * - * getInitialState: function () { - * return { - * currentPath: this.getCurrentPath() - * }; - * }, - * - * updatePath: function () { + * updatePath: function (path, actionType) { * this.setState({ - * currentPath: this.getCurrentPath() + * currentPath: path * }); * } * @@ -48,6 +43,8 @@ var PathState = { propTypes: { + fixedPath: React.PropTypes.string, + location: function (props, propName, componentName) { var location = props[propName]; @@ -59,8 +56,8 @@ var PathState = { getDefaultProps: function () { return { - location: canUseDOM ? HashLocation : null, - path: null + fixedPath: null, + location: canUseDOM ? HashLocation : null }; }, @@ -84,11 +81,16 @@ var PathState = { componentWillMount: function () { var location = this.getLocation(); + invariant( + this.props.fixedPath == null || this.getLocation() == null, + 'You cannot use a fixed path with a location. Choose one or the other' + ); + if (location && location.setup) location.setup(); if (this.updatePath) - this.updatePath(this.getCurrentPath(), this); + this.updatePath(this.getCurrentPath(), this.getCurrentActionType()); }, componentDidMount: function () { @@ -99,16 +101,17 @@ var PathState = { PathStore.removeChangeListener(this.handlePathChange); }, - handlePathChange: function (sender) { + handlePathChange: function () { if (this.isMounted() && this.updatePath) - this.updatePath(this.getCurrentPath(), sender); + this.updatePath(this.getCurrentPath(), this.getCurrentActionType()); }, - /** - * Returns the current URL path. - */ getCurrentPath: function () { - return PathStore.getCurrentPath(); + return this.props.fixedPath || PathStore.getCurrentPath(); + }, + + getCurrentActionType: function () { + return PathStore.getCurrentActionType(); } }; diff --git a/modules/mixins/RouteLookup.js b/modules/mixins/RouteLookup.js index 00e29d5227..da919d92c4 100644 --- a/modules/mixins/RouteLookup.js +++ b/modules/mixins/RouteLookup.js @@ -18,6 +18,13 @@ var RouteLookup = { return this.context.routeContainer.getRoutes(); }, + /** + * See RouteContainer#getNamedRoutes. + */ + getNamedRoutes: function () { + return this.context.routeContainer.getNamedRoutes(); + }, + /** * See RouteContainer#getRouteByName. */ diff --git a/modules/mixins/ScrollDelegate.js b/modules/mixins/ScrollDelegate.js index d853f88eb8..53444b8975 100644 --- a/modules/mixins/ScrollDelegate.js +++ b/modules/mixins/ScrollDelegate.js @@ -24,13 +24,13 @@ var ScrollDelegate = { * Updates the current scroll position according to the last * one that was recorded for the given path. */ - updateScroll: function (path, sender) { + updateScroll: function (path, actionType) { if (this._scrollPositions) { var behavior = this.getScrollBehavior(); var position = this._scrollPositions[path]; if (behavior && position) - behavior.updateScrollPosition(position, sender); + behavior.updateScrollPosition(position, actionType); } } diff --git a/modules/mixins/ScrollState.js b/modules/mixins/ScrollState.js index 623c0154dc..4187e93def 100644 --- a/modules/mixins/ScrollState.js +++ b/modules/mixins/ScrollState.js @@ -1,5 +1,6 @@ -var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; var invariant = require('react/lib/invariant'); +var canUseDOM = require('react/lib/ExecutionEnvironment').canUseDOM; +var LocationActions = require('../actions/LocationActions'); /** * A scroll behavior that attempts to imitate the default behavior @@ -7,12 +8,15 @@ var invariant = require('react/lib/invariant'); */ var ImitateBrowserBehavior = { - updateScrollPosition: function (position, sender) { - if (sender === window) { - window.scrollTo(position.x, position.y); - } else { - // Clicking on links always scrolls the window to the top. - window.scrollTo(0, 0); + updateScrollPosition: function (position, actionType) { + switch (actionType) { + case LocationActions.PUSH: + case LocationActions.REPLACE: + window.scrollTo(0, 0); + break; + case LocationActions.POP: + window.scrollTo(position.x, position.y); + break; } } @@ -69,7 +73,7 @@ var ScrollState = { var behavior = this.props.scrollBehavior; if (typeof behavior === 'string') - behavior = NAMED_SCROLL_STRATEGIES[behavior]; + behavior = NAMED_SCROLL_BEHAVIORS[behavior]; return behavior; }, diff --git a/modules/mixins/TransitionHandler.js b/modules/mixins/TransitionHandler.js index 1c9d134f64..b8a6538a7d 100644 --- a/modules/mixins/TransitionHandler.js +++ b/modules/mixins/TransitionHandler.js @@ -335,7 +335,7 @@ var TransitionHandler = { /** * See PathState. */ - updatePath: function (path, sender) { + updatePath: function (path, actionType) { if (this.state.path === path) return; // Nothing to do! @@ -351,7 +351,7 @@ var TransitionHandler = { self.props.onAbortedTransition.call(self, transition); } else { self.emitChange(); - self.updateScroll(path, sender); + self.updateScroll(path, actionType); } }); }, diff --git a/modules/stores/PathStore.js b/modules/stores/PathStore.js index d4e93e95fb..39dae44b47 100644 --- a/modules/stores/PathStore.js +++ b/modules/stores/PathStore.js @@ -5,11 +5,11 @@ var LocationDispatcher = require('../dispatchers/LocationDispatcher'); var CHANGE_EVENT = 'change'; var _events = new EventEmitter; -function notifyChange(sender) { - _events.emit(CHANGE_EVENT, sender); +function notifyChange() { + _events.emit(CHANGE_EVENT); } -var _currentPath; +var _currentPath, _currentActionType; /** * The PathStore keeps track of the current URL path. @@ -31,6 +31,13 @@ var PathStore = { return _currentPath; }, + /** + * Returns the type of the action that changed the URL. + */ + getCurrentActionType: function () { + return _currentActionType; + }, + dispatchToken: LocationDispatcher.register(function (payload) { var action = payload.action; @@ -41,7 +48,8 @@ var PathStore = { case LocationActions.POP: if (_currentPath !== action.path) { _currentPath = action.path; - notifyChange(action.sender); + _currentActionType = action.type; + notifyChange(); } break; } From c5d6051c81b9c2ac4e9540c5834280d585a0387b Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Fri, 3 Oct 2014 06:58:28 -0700 Subject: [PATCH 03/14] Move makePath/Href to Router.Transitions --- modules/mixins/RouteLookup.js | 17 +---------------- modules/mixins/Transitions.js | 20 +++++++++++++++++--- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/modules/mixins/RouteLookup.js b/modules/mixins/RouteLookup.js index da919d92c4..07a250fc92 100644 --- a/modules/mixins/RouteLookup.js +++ b/modules/mixins/RouteLookup.js @@ -7,8 +7,7 @@ var React = require('react'); var RouteLookup = { contextTypes: { - routeContainer: React.PropTypes.any.isRequired, - pathDelegate: React.PropTypes.any.isRequired + routeContainer: React.PropTypes.any.isRequired }, /** @@ -30,20 +29,6 @@ var RouteLookup = { */ getRouteByName: function (routeName) { return this.context.routeContainer.getRouteByName(routeName); - }, - - /** - * See PathDelegate#makePath. - */ - makePath: function (to, params, query) { - return this.context.pathDelegate.makePath(to, params, query); - }, - - /** - * See PathDelegate#makeHref. - */ - makeHref: function (to, params, query) { - return this.context.pathDelegate.makeHref(to, params, query); } }; diff --git a/modules/mixins/Transitions.js b/modules/mixins/Transitions.js index 0018b3e9a5..6d0cf7467a 100644 --- a/modules/mixins/Transitions.js +++ b/modules/mixins/Transitions.js @@ -9,25 +9,39 @@ var Transitions = { pathDelegate: React.PropTypes.any.isRequired }, + /** + * See PathDelegate#makePath. + */ + makePath: function (to, params, query) { + return this.context.pathDelegate.makePath(to, params, query); + }, + + /** + * See PathDelegate#makeHref. + */ + makeHref: function (to, params, query) { + return this.context.pathDelegate.makeHref(to, params, query); + }, + /** * See PathDelegate#transitionTo. */ transitionTo: function (to, params, query) { - return this.context.pathDelegate.transitionTo(to, params, query, this); + return this.context.pathDelegate.transitionTo(to, params, query); }, /** * See PathDelegate#replaceWith. */ replaceWith: function (to, params, query) { - return this.context.pathDelegate.replaceWith(to, params, query, this); + return this.context.pathDelegate.replaceWith(to, params, query); }, /** * See PathDelegate#goBack. */ goBack: function () { - return this.context.pathDelegate.goBack(this); + return this.context.pathDelegate.goBack(); } }; From d2aa7cb10f94a9612bb9b4ddc2f1392d38c83172 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Fri, 3 Oct 2014 06:58:50 -0700 Subject: [PATCH 04/14] [added] --- modules/mixins/PathState.js | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/mixins/PathState.js b/modules/mixins/PathState.js index 78f7a88318..0535295f4c 100644 --- a/modules/mixins/PathState.js +++ b/modules/mixins/PathState.js @@ -11,6 +11,7 @@ var PathStore = require('../stores/PathStore'); * A hash of { name: location } pairs. */ var NAMED_LOCATIONS = { + none: null, hash: HashLocation, history: HistoryLocation, refresh: RefreshLocation From 24086062b99d76331919bf598047d5b038e0ec64 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Fri, 3 Oct 2014 08:51:48 -0700 Subject: [PATCH 05/14] Update tests --- karma.conf.js | 4 +- modules/mixins/__tests__/PathDelegate-test.js | 96 +++++++ modules/mixins/__tests__/PathState-test.js | 66 +++++ .../mixins/__tests__/RouteContainer-test.js | 130 +++++++++ modules/stores/__tests__/PathStore-test.js | 100 +++++++ modules/utils/__tests__/Path-test.js | 257 ++++++++++++++++++ tests.js | 5 + 7 files changed, 656 insertions(+), 2 deletions(-) create mode 100644 modules/mixins/__tests__/PathDelegate-test.js create mode 100644 modules/mixins/__tests__/PathState-test.js create mode 100644 modules/mixins/__tests__/RouteContainer-test.js create mode 100644 modules/stores/__tests__/PathStore-test.js create mode 100644 modules/utils/__tests__/Path-test.js create mode 100644 tests.js diff --git a/karma.conf.js b/karma.conf.js index eb9b4b2265..2adb1eb024 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -6,13 +6,13 @@ module.exports = function(config) { frameworks: ['mocha', 'browserify'], files: [ - 'specs/main.js' + 'tests.js' ], exclude: [], preprocessors: { - 'specs/main.js': ['browserify'] + 'tests.js': ['browserify'] }, browserify: { diff --git a/modules/mixins/__tests__/PathDelegate-test.js b/modules/mixins/__tests__/PathDelegate-test.js new file mode 100644 index 0000000000..0895643c08 --- /dev/null +++ b/modules/mixins/__tests__/PathDelegate-test.js @@ -0,0 +1,96 @@ +var expect = require('expect'); +var React = require('react/addons'); +var ReactTestUtils = React.addons.TestUtils; +var Route = require('../../components/Route'); +var PathDelegate = require('../PathDelegate'); + +describe('PathDelegate', function () { + + var App = React.createClass({ + mixins: [ PathDelegate ], + render: function () { + return React.DOM.div(); + } + }); + + describe('makePath', function () { + describe('when there is a route with the given name', function () { + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App(null, + Route({ name: 'home', path: '/:username/home', handler: App }) + ) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('creates the correct path', function () { + expect(component.makePath('home', { username: 'mjackson' })).toEqual('/mjackson/home'); + }); + }); + + describe('when there is no route with the given name', function () { + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App() + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('creates the correct path', function () { + expect(function () { + component.makePath('home'); + }).toThrow('Unable to find a route named "home". Make sure you have a defined somewhere in your '); + }); + }); + }); + + describe('makeHref', function () { + describe('when using "hash" location', function () { + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App({ location: 'hash' }, + Route({ name: 'home', handler: App }) + ) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('puts a # in front of the URL', function () { + expect(component.makeHref('home')).toEqual('#/home'); + }); + }); + + describe('when using "history" location', function () { + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App({ location: 'history' }, + Route({ name: 'home', handler: App }) + ) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('returns the correct URL', function () { + expect(component.makeHref('home')).toEqual('/home'); + }); + }); + }); + +}); diff --git a/modules/mixins/__tests__/PathState-test.js b/modules/mixins/__tests__/PathState-test.js new file mode 100644 index 0000000000..f4e6ab6924 --- /dev/null +++ b/modules/mixins/__tests__/PathState-test.js @@ -0,0 +1,66 @@ +var expect = require('expect'); +var React = require('react/addons'); +var ReactTestUtils = React.addons.TestUtils; +var HashLocation = require('../../locations/HashLocation'); +var HistoryLocation = require('../../locations/HistoryLocation'); +var PathState = require('../PathState'); + +describe('PathState', function () { + var App = React.createClass({ + mixins: [ PathState ], + render: function () { + return React.DOM.div(); + } + }); + + describe('when using location="none"', function () { + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App({ location: 'none' }) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('has a null location', function () { + expect(component.getLocation()).toBe(null); + }); + }); + + describe('when using location="hash"', function () { + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App({ location: 'hash' }) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('has a null location', function () { + expect(component.getLocation()).toBe(HashLocation); + }); + }); + + describe('when using location="history"', function () { + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App({ location: 'history' }) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('has a null location', function () { + expect(component.getLocation()).toBe(HistoryLocation); + }); + }); +}); diff --git a/modules/mixins/__tests__/RouteContainer-test.js b/modules/mixins/__tests__/RouteContainer-test.js new file mode 100644 index 0000000000..a8dd1e6884 --- /dev/null +++ b/modules/mixins/__tests__/RouteContainer-test.js @@ -0,0 +1,130 @@ +var expect = require('expect'); +var React = require('react/addons'); +var ReactTestUtils = React.addons.TestUtils; +var Route = require('../../components/Route'); +var RouteContainer = require('../RouteContainer'); + +describe('RouteContainer', function () { + var App = React.createClass({ + mixins: [ RouteContainer ], + render: function () { + return React.DOM.div(); + } + }); + + describe('getRouteByName', function () { + describe('when the route exists', function () { + var component, route; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App(null, route = Route({ name: 'home', handler: App })) + ) + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('returns that route', function () { + expect(component.getRouteByName('home')).toBe(route); + }); + }); + + describe('when no such route exists', function () { + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App(null, Route({ name: 'home', handler: App })) + ) + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('returns null', function () { + expect(component.getRouteByName('about')).toBe(null); + }); + }); + }); + + describe('when a has no name or path', function () { + var component, route; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App(null, + route = Route({ handler: App }) + ) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('uses / as its path', function () { + expect(route.props.path).toEqual('/'); + }); + }); + + describe('when a has a name but no path', function () { + var component, route; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App(null, + route = Route({ name: 'home', handler: App }) + ) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('uses /:name as its path', function () { + expect(route.props.path).toEqual('/home'); + }); + }); + + describe('when a nested \'s path does not begin with a /', function () { + var component, childRoute; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App(null, + Route({ name: 'home', handler: App }, + childRoute = Route({ path: 'sub', handler: App }) + ) + ) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('extends the parent path', function () { + expect(childRoute.props.path).toEqual('/home/sub'); + }); + }); + + describe('when a nested \'s path begins with a /', function () { + var component, childRoute; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App(null, + Route({ name: 'home', handler: App }, + childRoute = Route({ path: '/sub', handler: App }) + ) + ) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('does not extend the parent path', function () { + expect(childRoute.props.path).toEqual('/sub'); + }); + }); +}); diff --git a/modules/stores/__tests__/PathStore-test.js b/modules/stores/__tests__/PathStore-test.js new file mode 100644 index 0000000000..7871013da1 --- /dev/null +++ b/modules/stores/__tests__/PathStore-test.js @@ -0,0 +1,100 @@ +var assert = require('assert'); +var expect = require('expect'); +var LocationActions = require('../../actions/LocationActions'); +var LocationDispatcher = require('../../dispatchers/LocationDispatcher'); +var PathStore = require('../PathStore'); + +describe('PathStore', function () { + beforeEach(function () { + LocationDispatcher.handleViewAction({ + type: LocationActions.PUSH, + path: '/' + }); + }); + + var changeWasFired; + function changeListener() { + changeWasFired = true; + } + + function setupChangeListener() { + changeWasFired = false; + PathStore.addChangeListener(changeListener); + } + + function teardownChangeListener() { + PathStore.removeChangeListener(changeListener); + } + + describe('when a new URL path is pushed', function () { + beforeEach(setupChangeListener); + beforeEach(function () { + LocationDispatcher.handleViewAction({ + type: LocationActions.PUSH, + path: '/push' + }); + }); + + afterEach(teardownChangeListener); + + it('updates the path', function () { + expect(PathStore.getCurrentPath()).toEqual('/push'); + }); + + it('updates the action type', function () { + expect(PathStore.getCurrentActionType()).toEqual(LocationActions.PUSH); + }); + + it('emits a change event', function () { + assert(changeWasFired); + }); + }); + + describe('when a URL path is replaced', function () { + beforeEach(setupChangeListener); + beforeEach(function () { + LocationDispatcher.handleViewAction({ + type: LocationActions.REPLACE, + path: '/replace' + }); + }); + + afterEach(teardownChangeListener); + + it('updates the path', function () { + expect(PathStore.getCurrentPath()).toEqual('/replace'); + }); + + it('updates the action type', function () { + expect(PathStore.getCurrentActionType()).toEqual(LocationActions.REPLACE); + }); + + it('emits a change event', function () { + assert(changeWasFired); + }); + }); + + describe('when a URL path is popped', function () { + beforeEach(setupChangeListener); + beforeEach(function () { + LocationDispatcher.handleViewAction({ + type: LocationActions.POP, + path: '/pop' + }); + }); + + afterEach(teardownChangeListener); + + it('updates the path', function () { + expect(PathStore.getCurrentPath()).toEqual('/pop'); + }); + + it('updates the action type', function () { + expect(PathStore.getCurrentActionType()).toEqual(LocationActions.POP); + }); + + it('emits a change event', function () { + assert(changeWasFired); + }); + }); +}); diff --git a/modules/utils/__tests__/Path-test.js b/modules/utils/__tests__/Path-test.js new file mode 100644 index 0000000000..42c9063ca7 --- /dev/null +++ b/modules/utils/__tests__/Path-test.js @@ -0,0 +1,257 @@ +var expect = require('expect'); +var Path = require('../Path'); + +describe('Path.extractParamNames', function () { + describe('when a pattern contains no dynamic segments', function () { + it('returns an empty array', function () { + expect(Path.extractParamNames('a/b/c')).toEqual([]); + }); + }); + + describe('when a pattern contains :a and :b dynamic segments', function () { + it('returns the correct names', function () { + expect(Path.extractParamNames('/comments/:a/:b/edit')).toEqual([ 'a', 'b' ]); + }); + }); + + describe('when a pattern has a *', function () { + it('uses the name "splat"', function () { + expect(Path.extractParamNames('/files/*.jpg')).toEqual([ 'splat' ]); + }); + }); +}); + +describe('Path.extractParams', function () { + describe('when a pattern does not have dynamic segments', function () { + var pattern = 'a/b/c'; + + describe('and the path matches', function () { + it('returns an empty object', function () { + expect(Path.extractParams(pattern, pattern)).toEqual({}); + }); + }); + + describe('and the path does not match', function () { + it('returns null', function () { + expect(Path.extractParams(pattern, 'd/e/f')).toBe(null); + }); + }); + }); + + describe('when a pattern has dynamic segments', function () { + var pattern = 'comments/:id.:ext/edit'; + + describe('and the path matches', function () { + it('returns an object with the params', function () { + expect(Path.extractParams(pattern, 'comments/abc.js/edit')).toEqual({ id: 'abc', ext: 'js' }); + }); + }); + + describe('and the path does not match', function () { + it('returns null', function () { + expect(Path.extractParams(pattern, 'users/123')).toBe(null); + }); + }); + + describe('and the path matches with a segment containing a .', function () { + it('returns an object with the params', function () { + expect(Path.extractParams(pattern, 'comments/foo.bar/edit')).toEqual({ id: 'foo', ext: 'bar' }); + }); + }); + }); + + describe('when a pattern has characters that have special URL encoding', function () { + var pattern = 'one, two'; + + describe('and the path matches', function () { + it('returns an empty object', function () { + expect(Path.extractParams(pattern, 'one%2C+two')).toEqual({}); + }); + }); + + describe('and the path does not match', function () { + it('returns null', function () { + expect(Path.extractParams(pattern, 'one+two')).toBe(null); + }); + }); + }); + + describe('when a pattern has dynamic segments and characters that have special URL encoding', function () { + var pattern = '/comments/:id/edit now'; + + describe('and the path matches', function () { + it('returns an object with the params', function () { + expect(Path.extractParams(pattern, '/comments/abc/edit+now')).toEqual({ id: 'abc' }); + }); + }); + + describe('and the path does not match', function () { + it('returns null', function () { + expect(Path.extractParams(pattern, '/users/123')).toBe(null); + }); + }); + }); + + describe('when a pattern has a *', function () { + describe('and the path matches', function () { + it('returns an object with the params', function () { + expect(Path.extractParams('/files/*', '/files/my/photo.jpg')).toEqual({ splat: 'my/photo.jpg' }); + expect(Path.extractParams('/files/*', '/files/my/photo.jpg.zip')).toEqual({ splat: 'my/photo.jpg.zip' }); + expect(Path.extractParams('/files/*.jpg', '/files/my/photo.jpg')).toEqual({ splat: 'my/photo' }); + }); + }); + + describe('and the path does not match', function () { + it('returns null', function () { + expect(Path.extractParams('/files/*.jpg', '/files/my/photo.png')).toBe(null); + }); + }); + }); + + describe('when a pattern has a ?', function () { + var pattern = '/archive/?:name?'; + + describe('and the path matches', function () { + it('returns an object with the params', function () { + expect(Path.extractParams(pattern, '/archive')).toEqual({ name: undefined }); + expect(Path.extractParams(pattern, '/archive/')).toEqual({ name: undefined }); + expect(Path.extractParams(pattern, '/archive/foo')).toEqual({ name: 'foo' }); + expect(Path.extractParams(pattern, '/archivefoo')).toEqual({ name: 'foo' }); + }); + }); + + describe('and the path does not match', function () { + it('returns null', function () { + expect(Path.extractParams(pattern, '/archiv')).toBe(null); + }); + }); + }); + + describe('when a param has dots', function () { + var pattern = '/:query/with/:domain'; + + describe('and the path matches', function () { + it('returns an object with the params', function () { + expect(Path.extractParams(pattern, '/foo/with/foo.app')).toEqual({ query: 'foo', domain: 'foo.app' }); + expect(Path.extractParams(pattern, '/foo.ap/with/foo')).toEqual({ query: 'foo.ap', domain: 'foo' }); + expect(Path.extractParams(pattern, '/foo.ap/with/foo.app')).toEqual({ query: 'foo.ap', domain: 'foo.app' }); + }); + }); + + describe('and the path does not match', function () { + it('returns null', function () { + expect(Path.extractParams(pattern, '/foo.ap')).toBe(null); + }); + }); + }); +}); + +describe('Path.injectParams', function () { + describe('when a pattern does not have dynamic segments', function () { + var pattern = 'a/b/c'; + + it('returns the pattern', function () { + expect(Path.injectParams(pattern, {})).toEqual(pattern); + }); + }); + + describe('when a pattern has dynamic segments', function () { + var pattern = 'comments/:id/edit'; + + describe('and a param is missing', function () { + it('throws an Error', function () { + expect(function () { + Path.injectParams(pattern, {}) + }).toThrow(Error); + }); + }); + + describe('and all params are present', function () { + it('returns the correct path', function () { + expect(Path.injectParams(pattern, { id: 'abc' })).toEqual('comments/abc/edit'); + }); + + it('returns the correct path when the value is 0', function () { + expect(Path.injectParams(pattern, { id: 0 })).toEqual('comments/0/edit'); + }); + }); + + describe('and some params have special URL encoding', function () { + it('returns the correct path', function () { + expect(Path.injectParams(pattern, { id: 'one, two' })).toEqual('comments/one%2C+two/edit'); + }); + }); + + describe('and a param has a forward slash', function () { + it('preserves the forward slash', function () { + expect(Path.injectParams(pattern, { id: 'the/id' })).toEqual('comments/the/id/edit'); + }); + }); + + describe('and some params contain dots', function () { + it('returns the correct path', function () { + expect(Path.injectParams(pattern, { id: 'alt.black.helicopter' })).toEqual('comments/alt.black.helicopter/edit'); + }); + }); + }); + + describe('when a pattern has multiple splats', function () { + it('returns the correct path', function () { + expect(Path.injectParams('/a/*/c/*', { splat: [ 'b', 'd' ] })).toEqual('/a/b/c/d'); + }); + }); +}); + +describe('Path.extractQuery', function () { + describe('when the path contains a query string', function () { + it('returns the parsed query object', function () { + expect(Path.extractQuery('/?id=def&show=true')).toEqual({ id: 'def', show: 'true' }); + }); + + it('properly handles arrays', function () { + expect(Path.extractQuery('/?id%5B%5D=a&id%5B%5D=b')).toEqual({ id: [ 'a', 'b' ] }); + }); + }); + + describe('when the path does not contain a query string', function () { + it('returns null', function () { + expect(Path.extractQuery('/a/b/c')).toBe(null); + }); + }); +}); + +describe('Path.withoutQuery', function () { + it('removes the query string', function () { + expect(Path.withoutQuery('/a/b/c?id=def')).toEqual('/a/b/c'); + }); +}); + +describe('Path.withQuery', function () { + it('appends the query string', function () { + expect(Path.withQuery('/a/b/c', { id: 'def' })).toEqual('/a/b/c?id=def'); + }); + + it('merges two query strings', function () { + expect(Path.withQuery('/path?a=b', { c: [ 'd', 'e' ]})).toEqual('/path?a=b&c%5B0%5D=d&c%5B1%5D=e'); + }); +}); + +describe('Path.normalize', function () { + describe('on a path with no slashes at the beginning', function () { + it('adds a slash', function () { + expect(Path.normalize('a/b/c')).toEqual('/a/b/c'); + }); + }); + + describe('on a path with a single slash at the beginning', function () { + it('preserves the slash', function () { + expect(Path.normalize('/a/b/c')).toEqual('/a/b/c'); + }); + }); + + describe('on a path with many slashes at the beginning', function () { + it('reduces them to a single slash', function () { + expect(Path.normalize('//a/b/c')).toEqual('/a/b/c'); + }); + }); +}); diff --git a/tests.js b/tests.js new file mode 100644 index 0000000000..eab05dd091 --- /dev/null +++ b/tests.js @@ -0,0 +1,5 @@ +require('./modules/mixins/__tests__/PathDelegate-test'); +require('./modules/mixins/__tests__/PathState-test'); +require('./modules/mixins/__tests__/RouteContainer-test'); +require('./modules/stores/__tests__/PathStore-test'); +require('./modules/utils/__tests__/Path-test'); From c456d8d5a0b01aef86fe7ac42aaf2756d4b756b7 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Fri, 3 Oct 2014 10:07:43 -0700 Subject: [PATCH 06/14] Add tests for DefaultRoute and NotFoundRoute --- modules/components/__tests__/DefaultRoute-test.js | 8 ++++++++ modules/components/__tests__/NotFoundRoute-test.js | 8 ++++++++ tests.js | 2 ++ 3 files changed, 18 insertions(+) create mode 100644 modules/components/__tests__/DefaultRoute-test.js create mode 100644 modules/components/__tests__/NotFoundRoute-test.js diff --git a/modules/components/__tests__/DefaultRoute-test.js b/modules/components/__tests__/DefaultRoute-test.js new file mode 100644 index 0000000000..b7d1a21e2f --- /dev/null +++ b/modules/components/__tests__/DefaultRoute-test.js @@ -0,0 +1,8 @@ +var expect = require('expect'); +var DefaultRoute = require('../DefaultRoute'); + +describe('DefaultRoute', function () { + it('has a null path', function () { + expect(DefaultRoute({ path: '/' }).props.path).toBe(null); + }); +}); diff --git a/modules/components/__tests__/NotFoundRoute-test.js b/modules/components/__tests__/NotFoundRoute-test.js new file mode 100644 index 0000000000..145d347241 --- /dev/null +++ b/modules/components/__tests__/NotFoundRoute-test.js @@ -0,0 +1,8 @@ +var expect = require('expect'); +var NotFoundRoute = require('../NotFoundRoute'); + +describe('NotFoundRoute', function () { + it('has a null path', function () { + expect(NotFoundRoute({ path: '/' }).props.path).toBe(null); + }); +}); diff --git a/tests.js b/tests.js index eab05dd091..776cb81d7f 100644 --- a/tests.js +++ b/tests.js @@ -1,3 +1,5 @@ +require('./modules/components/__tests__/DefaultRoute-test'); +require('./modules/components/__tests__/NotFoundRoute-test'); require('./modules/mixins/__tests__/PathDelegate-test'); require('./modules/mixins/__tests__/PathState-test'); require('./modules/mixins/__tests__/RouteContainer-test'); From d86ac40ce19faf9897dbbf538fef762a7a88509a Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Fri, 3 Oct 2014 10:15:28 -0700 Subject: [PATCH 07/14] Add tests for ActiveDelegate --- modules/mixins/ActiveDelegate.js | 18 +++- .../mixins/__tests__/ActiveDelegate-test.js | 102 ++++++++++++++++++ tests.js | 1 + 3 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 modules/mixins/__tests__/ActiveDelegate-test.js diff --git a/modules/mixins/ActiveDelegate.js b/modules/mixins/ActiveDelegate.js index a3c071b681..52d9d2bfdc 100644 --- a/modules/mixins/ActiveDelegate.js +++ b/modules/mixins/ActiveDelegate.js @@ -41,11 +41,23 @@ var ActiveDelegate = { }; }, + propTypes: { + initialState: React.PropTypes.object + }, + + getDefaultProps: function () { + return { + initialState: {} + }; + }, + getInitialState: function () { + var initialState = this.props.initialState; + return { - activeRoutes: [], - activeParams: {}, - activeQuery: {} + activeRoutes: initialState.activeRoutes || [], + activeParams: initialState.activeParams || {}, + activeQuery: initialState.activeQuery || {} }; }, diff --git a/modules/mixins/__tests__/ActiveDelegate-test.js b/modules/mixins/__tests__/ActiveDelegate-test.js new file mode 100644 index 0000000000..c7a1e35cce --- /dev/null +++ b/modules/mixins/__tests__/ActiveDelegate-test.js @@ -0,0 +1,102 @@ +var assert = require('assert'); +var React = require('react/addons'); +var ReactTestUtils = React.addons.TestUtils; +var Route = require('../../components/Route'); +var ActiveDelegate = require('../ActiveDelegate'); + +describe('ActiveDelegate', function () { + var App = React.createClass({ + mixins: [ ActiveDelegate ], + render: function () { + return React.DOM.div(); + } + }); + + describe('when a route is active', function () { + var route; + beforeEach(function () { + route = Route({ name: 'products', handler: App }); + }); + + describe('and it has no params', function () { + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App({ + initialState: { + activeRoutes: [ route ] + } + }) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('is active', function () { + assert(component.isActive('products')); + }); + }); + + describe('and the right params are given', function () { + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App({ + initialState: { + activeRoutes: [ route ], + activeParams: { id: '123', show: 'true', variant: 456 }, + activeQuery: { search: 'abc', limit: 789 } + } + }) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + describe('and no query is used', function () { + it('is active', function () { + assert(component.isActive('products', { id: 123, variant: '456' })); + }); + }); + + describe('and a matching query is used', function () { + it('is active', function () { + assert(component.isActive('products', { id: 123 }, { search: 'abc', limit: '789' })); + }); + }); + + describe('but the query does not match', function () { + it('is not active', function () { + assert(component.isActive('products', { id: 123 }, { search: 'def', limit: '123' }) === false); + }); + }); + }); + + describe('and the wrong params are given', function () { + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App({ + initialState: { + activeRoutes: [ route ], + activeParams: { id: 123 } + } + }) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('is not active', function () { + assert(component.isActive('products', { id: 345 }) === false); + }); + }); + }); + +}); diff --git a/tests.js b/tests.js index 776cb81d7f..a4250f0e67 100644 --- a/tests.js +++ b/tests.js @@ -1,5 +1,6 @@ require('./modules/components/__tests__/DefaultRoute-test'); require('./modules/components/__tests__/NotFoundRoute-test'); +require('./modules/mixins/__tests__/ActiveDelegate-test'); require('./modules/mixins/__tests__/PathDelegate-test'); require('./modules/mixins/__tests__/PathState-test'); require('./modules/mixins/__tests__/RouteContainer-test'); From 59fa140fc99d2438e3c23660a106e2b688435541 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Fri, 3 Oct 2014 10:26:02 -0700 Subject: [PATCH 08/14] More tests --- .../components/__tests__/DefaultRoute-test.js | 53 ++++++++++++++++++- .../__tests__/NotFoundRoute-test.js | 51 ++++++++++++++++++ 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/modules/components/__tests__/DefaultRoute-test.js b/modules/components/__tests__/DefaultRoute-test.js index b7d1a21e2f..1f683eea75 100644 --- a/modules/components/__tests__/DefaultRoute-test.js +++ b/modules/components/__tests__/DefaultRoute-test.js @@ -1,8 +1,59 @@ var expect = require('expect'); +var React = require('react/addons'); +var ReactTestUtils = React.addons.TestUtils; +var RouteContainer = require('../../mixins/RouteContainer'); +var Route = require('../Route'); var DefaultRoute = require('../DefaultRoute'); -describe('DefaultRoute', function () { +describe('A DefaultRoute', function () { it('has a null path', function () { expect(DefaultRoute({ path: '/' }).props.path).toBe(null); }); + + var App = React.createClass({ + mixins: [ RouteContainer ], + render: function () { + return React.DOM.div(); + } + }); + + describe('at the root of a container', function () { + var component, route; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App(null, + route = DefaultRoute({ handler: App }) + ) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('becomes its container\'s defaultRoute', function () { + expect(component.props.defaultRoute).toBe(route); + }); + }); + + describe('nested in another Route', function () { + var component, route, defaultRoute; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App(null, + route = Route({ handler: App }, + defaultRoute = DefaultRoute({ handler: App }) + ) + ) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('becomes that route\'s defaultRoute', function () { + expect(route.props.defaultRoute).toBe(defaultRoute); + }); + }); }); diff --git a/modules/components/__tests__/NotFoundRoute-test.js b/modules/components/__tests__/NotFoundRoute-test.js index 145d347241..c81836e425 100644 --- a/modules/components/__tests__/NotFoundRoute-test.js +++ b/modules/components/__tests__/NotFoundRoute-test.js @@ -1,8 +1,59 @@ var expect = require('expect'); +var React = require('react/addons'); +var ReactTestUtils = React.addons.TestUtils; +var RouteContainer = require('../../mixins/RouteContainer'); +var Route = require('../Route'); var NotFoundRoute = require('../NotFoundRoute'); describe('NotFoundRoute', function () { it('has a null path', function () { expect(NotFoundRoute({ path: '/' }).props.path).toBe(null); }); + + var App = React.createClass({ + mixins: [ RouteContainer ], + render: function () { + return React.DOM.div(); + } + }); + + describe('at the root of a container', function () { + var component, route; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App(null, + route = NotFoundRoute({ handler: App }) + ) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('becomes its container\'s notFoundRoute', function () { + expect(component.props.notFoundRoute).toBe(route); + }); + }); + + describe('nested in another Route', function () { + var component, route, notFoundRoute; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App(null, + route = Route({ handler: App }, + notFoundRoute = NotFoundRoute({ handler: App }) + ) + ) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('becomes that route\'s notFoundRoute', function () { + expect(route.props.notFoundRoute).toBe(notFoundRoute); + }); + }); }); From e394d14744ec9150dd1fd9c5ec8100bb44695779 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Fri, 3 Oct 2014 18:32:54 -0700 Subject: [PATCH 09/14] More tests --- .../components/__tests__/DefaultRoute-test.js | 40 +++ .../__tests__/NotFoundRoute-test.js | 42 ++- modules/mixins/__tests__/AsyncState-test.js | 53 ++++ modules/stores/PathStore.js | 4 + specs/ActiveDelegate.spec.js | 89 ------ specs/AsyncState.spec.js | 46 ---- specs/DefaultRoute.spec.js | 77 ------ specs/NotFoundRoute.spec.js | 64 ----- specs/Path.spec.js | 257 ------------------ specs/PathStore.spec.js | 55 ---- specs/RouteStore.spec.js | 118 -------- tests.js | 1 + 12 files changed, 139 insertions(+), 707 deletions(-) create mode 100644 modules/mixins/__tests__/AsyncState-test.js delete mode 100644 specs/ActiveDelegate.spec.js delete mode 100644 specs/AsyncState.spec.js delete mode 100644 specs/DefaultRoute.spec.js delete mode 100644 specs/NotFoundRoute.spec.js delete mode 100644 specs/Path.spec.js delete mode 100644 specs/PathStore.spec.js delete mode 100644 specs/RouteStore.spec.js diff --git a/modules/components/__tests__/DefaultRoute-test.js b/modules/components/__tests__/DefaultRoute-test.js index 1f683eea75..3ba72c4b67 100644 --- a/modules/components/__tests__/DefaultRoute-test.js +++ b/modules/components/__tests__/DefaultRoute-test.js @@ -1,7 +1,10 @@ +var assert = require('assert'); var expect = require('expect'); var React = require('react/addons'); var ReactTestUtils = React.addons.TestUtils; var RouteContainer = require('../../mixins/RouteContainer'); +var TransitionHandler = require('../../mixins/TransitionHandler'); +var PathStore = require('../../stores/PathStore'); var Route = require('../Route'); var DefaultRoute = require('../DefaultRoute'); @@ -57,3 +60,40 @@ describe('A DefaultRoute', function () { }); }); }); + +describe('when no child routes match a URL, but the parent\'s path matches', function () { + var App = React.createClass({ + mixins: [ TransitionHandler ], + render: function () { + return React.DOM.div(); + } + }); + + var component, rootRoute, defaultRoute; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App({ location: 'none' }, + rootRoute = Route({ name: 'user', path: '/users/:id', handler: App }, + Route({ name: 'home', path: '/users/:id/home', handler: App }), + // Make it the middle sibling to test order independence. + defaultRoute = DefaultRoute({ handler: App }), + Route({ name: 'news', path: '/users/:id/news', handler: App }) + ) + ) + ) + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + // For some reason unmountComponentAtNode doesn't call componentWillUnmount :/ + PathStore.removeAllChangeListeners(); + }); + + it('matches the default route', function () { + var matches = component.match('/users/5'); + assert(matches); + expect(matches.length).toEqual(2); + expect(matches[0].route).toBe(rootRoute); + expect(matches[1].route).toBe(defaultRoute); + }); +}); diff --git a/modules/components/__tests__/NotFoundRoute-test.js b/modules/components/__tests__/NotFoundRoute-test.js index c81836e425..6ceb9405fc 100644 --- a/modules/components/__tests__/NotFoundRoute-test.js +++ b/modules/components/__tests__/NotFoundRoute-test.js @@ -1,11 +1,14 @@ +var assert = require('assert'); var expect = require('expect'); var React = require('react/addons'); var ReactTestUtils = React.addons.TestUtils; var RouteContainer = require('../../mixins/RouteContainer'); +var TransitionHandler = require('../../mixins/TransitionHandler'); +var PathStore = require('../../stores/PathStore'); var Route = require('../Route'); var NotFoundRoute = require('../NotFoundRoute'); -describe('NotFoundRoute', function () { +describe('A NotFoundRoute', function () { it('has a null path', function () { expect(NotFoundRoute({ path: '/' }).props.path).toBe(null); }); @@ -57,3 +60,40 @@ describe('NotFoundRoute', function () { }); }); }); + +describe('when no child routes match a URL, but the beginning of the parent\'s path matches', function () { + var App = React.createClass({ + mixins: [ TransitionHandler ], + render: function () { + return React.DOM.div(); + } + }); + + var component, rootRoute, notFoundRoute; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + App({ location: 'none' }, + rootRoute = Route({ name: 'user', path: '/users/:id', handler: App }, + Route({ name: 'home', path: '/users/:id/home', handler: App }), + // Make it the middle sibling to test order independence. + notFoundRoute = NotFoundRoute({ handler: App }), + Route({ name: 'news', path: '/users/:id/news', handler: App }) + ) + ) + ) + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + // For some reason unmountComponentAtNode doesn't call componentWillUnmount :/ + PathStore.removeAllChangeListeners(); + }); + + it('matches the NotFoundRoute', function () { + var matches = component.match('/users/5/not-found'); + assert(matches); + expect(matches.length).toEqual(2); + expect(matches[0].route).toBe(rootRoute); + expect(matches[1].route).toBe(notFoundRoute); + }); +}); diff --git a/modules/mixins/__tests__/AsyncState-test.js b/modules/mixins/__tests__/AsyncState-test.js new file mode 100644 index 0000000000..7344987bc3 --- /dev/null +++ b/modules/mixins/__tests__/AsyncState-test.js @@ -0,0 +1,53 @@ +var expect = require('expect'); +var React = require('react/addons'); +var ReactTestUtils = React.addons.TestUtils; +var Promise = require('../../utils/Promise'); +var AsyncState = require('../AsyncState'); + +describe('AsyncState', function () { + + describe('a component that fetches part of its state asynchronously', function () { + var User = React.createClass({ + mixins: [ AsyncState ], + statics: { + getInitialAsyncState: function (params, query, setState) { + setState({ + immediateValue: 'immediate' + }); + + setTimeout(function () { + setState({ + delayedValue: 'delayed' + }); + }); + + return { + promisedValue: Promise.resolve('promised') + }; + } + }, + render: function () { + return null; + } + }); + + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument(User()); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('resolves all state variables correctly', function (done) { + setTimeout(function () { + expect(component.state.immediateValue).toEqual('immediate'); + expect(component.state.delayedValue).toEqual('delayed'); + expect(component.state.promisedValue).toEqual('promised'); + done(); + }, 20); + }); + }); + +}); diff --git a/modules/stores/PathStore.js b/modules/stores/PathStore.js index 39dae44b47..5bce5baa4e 100644 --- a/modules/stores/PathStore.js +++ b/modules/stores/PathStore.js @@ -24,6 +24,10 @@ var PathStore = { _events.removeListener(CHANGE_EVENT, listener); }, + removeAllChangeListeners: function () { + _events.removeAllListeners(CHANGE_EVENT); + }, + /** * Returns the current URL path. */ diff --git a/specs/ActiveDelegate.spec.js b/specs/ActiveDelegate.spec.js deleted file mode 100644 index 16ba1b25aa..0000000000 --- a/specs/ActiveDelegate.spec.js +++ /dev/null @@ -1,89 +0,0 @@ -require('./helper'); -var Route = require('../modules/components/Route'); -var ActiveDelegate = require('../modules/mixins/ActiveDelegate'); - -var App = React.createClass({ - displayName: 'App', - mixins: [ ActiveDelegate ], - getInitialState: function () { - return this.props.initialState; - }, - render: function () { - return React.DOM.div(); - } -}); - -describe('when a Route is active', function () { - var route; - beforeEach(function () { - route = Route({ name: 'products', handler: App }); - }); - - describe('and it has no params', function () { - var app; - beforeEach(function () { - app = ReactTestUtils.renderIntoDocument( - App({ - initialState: { - activeRoutes: [ route ] - } - }) - ); - }); - - it('is active', function () { - assert(app.isActive('products')); - }); - }); - - describe('and the right params are given', function () { - var app; - beforeEach(function () { - app = ReactTestUtils.renderIntoDocument( - App({ - initialState: { - activeRoutes: [ route ], - activeParams: { id: '123', show: 'true', variant: 456 }, - activeQuery: { search: 'abc', limit: 789 } - } - }) - ); - }); - - describe('and no query is used', function () { - it('is active', function () { - assert(app.isActive('products', { id: 123, variant: '456' })); - }); - }); - - describe('and a matching query is used', function () { - it('is active', function () { - assert(app.isActive('products', { id: 123 }, { search: 'abc', limit: '789' })); - }); - }); - - describe('but the query does not match', function () { - it('is not active', function () { - refute(app.isActive('products', { id: 123 }, { search: 'def', limit: '123' })); - }); - }); - }); - - describe('and the wrong params are given', function () { - var app; - beforeEach(function () { - app = ReactTestUtils.renderIntoDocument( - App({ - initialState: { - activeRoutes: [ route ], - activeParams: { id: 123 } - } - }) - ); - }); - - it('is not active', function () { - refute(app.isActive('products', { id: 345 })); - }); - }); -}); diff --git a/specs/AsyncState.spec.js b/specs/AsyncState.spec.js deleted file mode 100644 index a1e5dd1922..0000000000 --- a/specs/AsyncState.spec.js +++ /dev/null @@ -1,46 +0,0 @@ -require('./helper'); -var Promise = require('when/lib/Promise'); -var AsyncState = require('../modules/mixins/AsyncState'); - -describe('AsyncState', function () { - - describe('a component that fetches part of its state asynchronously', function () { - it('resolves all state variables correctly', function (done) { - var User = React.createClass({ - mixins: [ AsyncState ], - statics: { - getInitialAsyncState: function (params, query, setState) { - setState({ - immediateValue: 'immediate' - }); - - setTimeout(function () { - setState({ - delayedValue: 'delayed' - }); - }); - - return { - promisedValue: Promise.resolve('promised') - }; - } - }, - render: function () { - return null; - } - }); - - var user = ReactTestUtils.renderIntoDocument( - User() - ); - - setTimeout(function () { - expect(user.state.immediateValue).toEqual('immediate'); - expect(user.state.delayedValue).toEqual('delayed'); - expect(user.state.promisedValue).toEqual('promised'); - done(); - }, 20); - }); - }); - -}); diff --git a/specs/DefaultRoute.spec.js b/specs/DefaultRoute.spec.js deleted file mode 100644 index 5601d18c1a..0000000000 --- a/specs/DefaultRoute.spec.js +++ /dev/null @@ -1,77 +0,0 @@ -require('./helper'); -var RouteStore = require('../modules/stores/RouteStore'); -var DefaultRoute = require('../modules/components/DefaultRoute'); -var Route = require('../modules/components/Route'); -var Routes = require('../modules/components/Routes'); - -var App = React.createClass({ - displayName: 'App', - render: function () { - return React.DOM.div(); - } -}); - -describe('when registering a DefaultRoute', function () { - describe('nested inside a Route component', function () { - it('becomes that Route\'s defaultRoute', function () { - var defaultRoute; - var route = Route({ handler: App }, - defaultRoute = DefaultRoute({ handler: App }) - ); - - RouteStore.registerRoute(route); - expect(route.props.defaultRoute).toBe(defaultRoute); - RouteStore.unregisterRoute(route); - }); - }); - - describe('nested inside a Routes component', function () { - it('becomes that Routes\' defaultRoute', function () { - var defaultRoute; - var routes = Routes({ handler: App }, - defaultRoute = DefaultRoute({ handler: App }) - ); - - RouteStore.registerRoute(defaultRoute, routes); - expect(routes.props.defaultRoute).toBe(defaultRoute); - RouteStore.unregisterRoute(defaultRoute); - }); - }); - - describe('that has a name', function () { - it('is able to be looked up by name', function () { - var defaultRoute; - var routes = Routes({ handler: App }, - defaultRoute = DefaultRoute({ name: 'home', handler: App }) - ); - - RouteStore.registerRoute(defaultRoute, routes); - expect(RouteStore.getRouteByName('home')).toBe(defaultRoute); - RouteStore.unregisterRoute(defaultRoute); - }); - }); -}); - -describe('when no child routes match a URL, but the parent matches', function () { - it('matches the default route', function () { - var defaultRoute; - var routes = ReactTestUtils.renderIntoDocument( - Routes(null, - Route({ name: 'user', path: '/users/:id', handler: App }, - Route({ name: 'home', path: '/users/:id/home', handler: App }), - // Make it the middle sibling to test order independence. - defaultRoute = DefaultRoute({ handler: App }), - Route({ name: 'news', path: '/users/:id/news', handler: App }) - ) - ) - ); - - var matches = routes.match('/users/5'); - assert(matches); - expect(matches.length).toEqual(2); - - expect(matches[1].route).toBe(defaultRoute); - - expect(matches[0].route.props.name).toEqual('user'); - }); -}); diff --git a/specs/NotFoundRoute.spec.js b/specs/NotFoundRoute.spec.js deleted file mode 100644 index 41f8bf7817..0000000000 --- a/specs/NotFoundRoute.spec.js +++ /dev/null @@ -1,64 +0,0 @@ -require('./helper'); -var RouteStore = require('../modules/stores/RouteStore'); -var NotFoundRoute = require('../modules/components/NotFoundRoute'); -var Route = require('../modules/components/Route'); -var Routes = require('../modules/components/Routes'); - -var App = React.createClass({ - displayName: 'App', - render: function () { - return React.DOM.div(); - } -}); - -describe('when registering a NotFoundRoute', function () { - describe('nested inside a Route component', function () { - it('becomes that Route\'s notFoundRoute', function () { - var notFoundRoute; - var route = Route({ handler: App }, - notFoundRoute = NotFoundRoute({ handler: App }) - ); - - RouteStore.registerRoute(route); - expect(route.props.notFoundRoute).toBe(notFoundRoute); - RouteStore.unregisterRoute(route); - }); - }); - - describe('nested inside a Routes component', function () { - it('becomes that Routes\' notFoundRoute', function () { - var notFoundRoute; - var routes = Routes({ handler: App }, - notFoundRoute = NotFoundRoute({ handler: App }) - ); - - RouteStore.registerRoute(notFoundRoute, routes); - expect(routes.props.notFoundRoute).toBe(notFoundRoute); - RouteStore.unregisterRoute(notFoundRoute); - }); - }); -}); - -describe('when no child routes match a URL, but the beginning of the parent\'s path matches', function () { - it('matches the default route', function () { - var notFoundRoute; - var routes = ReactTestUtils.renderIntoDocument( - Routes(null, - Route({ name: 'user', path: '/users/:id', handler: App }, - Route({ name: 'home', path: '/users/:id/home', handler: App }), - // Make it the middle sibling to test order independence. - notFoundRoute = NotFoundRoute({ handler: App }), - Route({ name: 'news', path: '/users/:id/news', handler: App }) - ) - ) - ); - - var matches = routes.match('/users/5/not-found'); - assert(matches); - expect(matches.length).toEqual(2); - - expect(matches[1].route).toBe(notFoundRoute); - - expect(matches[0].route.props.name).toEqual('user'); - }); -}); diff --git a/specs/Path.spec.js b/specs/Path.spec.js deleted file mode 100644 index bacbf07a70..0000000000 --- a/specs/Path.spec.js +++ /dev/null @@ -1,257 +0,0 @@ -require('./helper'); -var Path = require('../modules/utils/Path'); - -describe('Path.extractParamNames', function () { - describe('when a pattern contains no dynamic segments', function () { - it('returns an empty array', function () { - expect(Path.extractParamNames('a/b/c')).toEqual([]); - }); - }); - - describe('when a pattern contains :a and :b dynamic segments', function () { - it('returns the correct names', function () { - expect(Path.extractParamNames('/comments/:a/:b/edit')).toEqual([ 'a', 'b' ]); - }); - }); - - describe('when a pattern has a *', function () { - it('uses the name "splat"', function () { - expect(Path.extractParamNames('/files/*.jpg')).toEqual([ 'splat' ]); - }); - }); -}); - -describe('Path.extractParams', function () { - describe('when a pattern does not have dynamic segments', function () { - var pattern = 'a/b/c'; - - describe('and the path matches', function () { - it('returns an empty object', function () { - expect(Path.extractParams(pattern, pattern)).toEqual({}); - }); - }); - - describe('and the path does not match', function () { - it('returns null', function () { - expect(Path.extractParams(pattern, 'd/e/f')).toBe(null); - }); - }); - }); - - describe('when a pattern has dynamic segments', function () { - var pattern = 'comments/:id.:ext/edit'; - - describe('and the path matches', function () { - it('returns an object with the params', function () { - expect(Path.extractParams(pattern, 'comments/abc.js/edit')).toEqual({ id: 'abc', ext: 'js' }); - }); - }); - - describe('and the path does not match', function () { - it('returns null', function () { - expect(Path.extractParams(pattern, 'users/123')).toBe(null); - }); - }); - - describe('and the path matches with a segment containing a .', function () { - it('returns an object with the params', function () { - expect(Path.extractParams(pattern, 'comments/foo.bar/edit')).toEqual({ id: 'foo', ext: 'bar' }); - }); - }); - }); - - describe('when a pattern has characters that have special URL encoding', function () { - var pattern = 'one, two'; - - describe('and the path matches', function () { - it('returns an empty object', function () { - expect(Path.extractParams(pattern, 'one%2C+two')).toEqual({}); - }); - }); - - describe('and the path does not match', function () { - it('returns null', function () { - expect(Path.extractParams(pattern, 'one+two')).toBe(null); - }); - }); - }); - - describe('when a pattern has dynamic segments and characters that have special URL encoding', function () { - var pattern = '/comments/:id/edit now'; - - describe('and the path matches', function () { - it('returns an object with the params', function () { - expect(Path.extractParams(pattern, '/comments/abc/edit+now')).toEqual({ id: 'abc' }); - }); - }); - - describe('and the path does not match', function () { - it('returns null', function () { - expect(Path.extractParams(pattern, '/users/123')).toBe(null); - }); - }); - }); - - describe('when a pattern has a *', function () { - describe('and the path matches', function () { - it('returns an object with the params', function () { - expect(Path.extractParams('/files/*', '/files/my/photo.jpg')).toEqual({ splat: 'my/photo.jpg' }); - expect(Path.extractParams('/files/*', '/files/my/photo.jpg.zip')).toEqual({ splat: 'my/photo.jpg.zip' }); - expect(Path.extractParams('/files/*.jpg', '/files/my/photo.jpg')).toEqual({ splat: 'my/photo' }); - }); - }); - - describe('and the path does not match', function () { - it('returns null', function () { - expect(Path.extractParams('/files/*.jpg', '/files/my/photo.png')).toBe(null); - }); - }); - }); - - describe('when a pattern has a ?', function () { - var pattern = '/archive/?:name?'; - - describe('and the path matches', function () { - it('returns an object with the params', function () { - expect(Path.extractParams(pattern, '/archive')).toEqual({ name: undefined }); - expect(Path.extractParams(pattern, '/archive/')).toEqual({ name: undefined }); - expect(Path.extractParams(pattern, '/archive/foo')).toEqual({ name: 'foo' }); - expect(Path.extractParams(pattern, '/archivefoo')).toEqual({ name: 'foo' }); - }); - }); - - describe('and the path does not match', function () { - it('returns null', function () { - expect(Path.extractParams(pattern, '/archiv')).toBe(null); - }); - }); - }); - - describe('when a param has dots', function () { - var pattern = '/:query/with/:domain'; - - describe('and the path matches', function () { - it('returns an object with the params', function () { - expect(Path.extractParams(pattern, '/foo/with/foo.app')).toEqual({ query: 'foo', domain: 'foo.app' }); - expect(Path.extractParams(pattern, '/foo.ap/with/foo')).toEqual({ query: 'foo.ap', domain: 'foo' }); - expect(Path.extractParams(pattern, '/foo.ap/with/foo.app')).toEqual({ query: 'foo.ap', domain: 'foo.app' }); - }); - }); - - describe('and the path does not match', function () { - it('returns null', function () { - expect(Path.extractParams(pattern, '/foo.ap')).toBe(null); - }); - }); - }); -}); - -describe('Path.injectParams', function () { - describe('when a pattern does not have dynamic segments', function () { - var pattern = 'a/b/c'; - - it('returns the pattern', function () { - expect(Path.injectParams(pattern, {})).toEqual(pattern); - }); - }); - - describe('when a pattern has dynamic segments', function () { - var pattern = 'comments/:id/edit'; - - describe('and a param is missing', function () { - it('throws an Error', function () { - expect(function () { - Path.injectParams(pattern, {}) - }).toThrow(Error); - }); - }); - - describe('and all params are present', function () { - it('returns the correct path', function () { - expect(Path.injectParams(pattern, { id: 'abc' })).toEqual('comments/abc/edit'); - }); - - it('returns the correct path when the value is 0', function () { - expect(Path.injectParams(pattern, { id: 0 })).toEqual('comments/0/edit'); - }); - }); - - describe('and some params have special URL encoding', function () { - it('returns the correct path', function () { - expect(Path.injectParams(pattern, { id: 'one, two' })).toEqual('comments/one%2C+two/edit'); - }); - }); - - describe('and a param has a forward slash', function () { - it('preserves the forward slash', function () { - expect(Path.injectParams(pattern, { id: 'the/id' })).toEqual('comments/the/id/edit'); - }); - }); - - describe('and some params contain dots', function () { - it('returns the correct path', function () { - expect(Path.injectParams(pattern, { id: 'alt.black.helicopter' })).toEqual('comments/alt.black.helicopter/edit'); - }); - }); - }); - - describe('when a pattern has multiple splats', function () { - it('returns the correct path', function () { - expect(Path.injectParams('/a/*/c/*', { splat: [ 'b', 'd' ] })).toEqual('/a/b/c/d'); - }); - }); -}); - -describe('Path.extractQuery', function () { - describe('when the path contains a query string', function () { - it('returns the parsed query object', function () { - expect(Path.extractQuery('/?id=def&show=true')).toEqual({ id: 'def', show: 'true' }); - }); - - it('properly handles arrays', function () { - expect(Path.extractQuery('/?id%5B%5D=a&id%5B%5D=b')).toEqual({ id: [ 'a', 'b' ] }); - }); - }); - - describe('when the path does not contain a query string', function () { - it('returns null', function () { - expect(Path.extractQuery('/a/b/c')).toBe(null); - }); - }); -}); - -describe('Path.withoutQuery', function () { - it('removes the query string', function () { - expect(Path.withoutQuery('/a/b/c?id=def')).toEqual('/a/b/c'); - }); -}); - -describe('Path.withQuery', function () { - it('appends the query string', function () { - expect(Path.withQuery('/a/b/c', { id: 'def' })).toEqual('/a/b/c?id=def'); - }); - - it('merges two query strings', function () { - expect(Path.withQuery('/path?a=b', { c: [ 'd', 'e' ]})).toEqual('/path?a=b&c%5B0%5D=d&c%5B1%5D=e'); - }); -}); - -describe('Path.normalize', function () { - describe('on a path with no slashes at the beginning', function () { - it('adds a slash', function () { - expect(Path.normalize('a/b/c')).toEqual('/a/b/c'); - }); - }); - - describe('on a path with a single slash at the beginning', function () { - it('preserves the slash', function () { - expect(Path.normalize('/a/b/c')).toEqual('/a/b/c'); - }); - }); - - describe('on a path with many slashes at the beginning', function () { - it('reduces them to a single slash', function () { - expect(Path.normalize('//a/b/c')).toEqual('/a/b/c'); - }); - }); -}); diff --git a/specs/PathStore.spec.js b/specs/PathStore.spec.js deleted file mode 100644 index 1b199d1031..0000000000 --- a/specs/PathStore.spec.js +++ /dev/null @@ -1,55 +0,0 @@ -require('./helper'); -var transitionTo = require('../modules/actions/LocationActions').transitionTo; -var replaceWith = require('../modules/actions/LocationActions').replaceWith; -var goBack = require('../modules/actions/LocationActions').goBack; -var getCurrentPath = require('../modules/stores/PathStore').getCurrentPath; - -describe('PathStore', function () { - - beforeEach(function () { - transitionTo('/one'); - }); - - describe('when a new path is pushed to the URL', function () { - beforeEach(function () { - transitionTo('/two'); - }); - - it('has the correct path', function () { - expect(getCurrentPath()).toEqual('/two'); - }); - }); - - describe('when a new path is used to replace the URL', function () { - beforeEach(function () { - transitionTo('/two'); - replaceWith('/three'); - }); - - it('has the correct path', function () { - expect(getCurrentPath()).toEqual('/three'); - }); - - describe('going back in history', function () { - beforeEach(function () { - goBack(); - }); - - it('has the path before the one that was replaced', function () { - expect(getCurrentPath()).toEqual('/one'); - }); - }); - }); - - describe('when going back in history', function () { - beforeEach(function () { - transitionTo('/two'); - goBack(); - }); - - it('has the correct path', function () { - expect(getCurrentPath()).toEqual('/one'); - }); - }); - -}); diff --git a/specs/RouteStore.spec.js b/specs/RouteStore.spec.js deleted file mode 100644 index 9cf5e1b3f5..0000000000 --- a/specs/RouteStore.spec.js +++ /dev/null @@ -1,118 +0,0 @@ -require('./helper'); -var RouteStore = require('../modules/stores/RouteStore'); -var Route = require('../modules/components/Route'); - -var App = React.createClass({ - displayName: 'App', - render: function () { - return React.DOM.div(); - } -}); - -describe('when a route with a given name is not present', function () { - it('returns null', function () { - expect(RouteStore.getRouteByName('products')).toBe(null); - }); -}); - -describe('when a route is looked up by name', function () { - var route; - beforeEach(function () { - route = Route({ name: 'products', handler: App }); - RouteStore.registerRoute(route); - }); - - it('returns that route', function () { - expect(RouteStore.getRouteByName('products')).toEqual(route); - }); -}); - -describe('when registering a route', function () { - - describe('that starts with /', function() { - it('does not inherit the parent path', function() { - var child; - var route = Route({ name: 'home', handler: App }, - child = Route({ path: '/foo', handler: App }) - ); - RouteStore.registerRoute(route); - expect(child.props.path).toEqual('/foo'); - RouteStore.unregisterRoute(route); - }); - }); - - describe('that does not start with /', function() { - it('inherits the parent path', function() { - var child; - var route = Route({ name: 'home', handler: App }, - child = Route({ path: 'foo', handler: App }) - ); - RouteStore.registerRoute(route); - expect(child.props.path).toEqual('/home/foo'); - RouteStore.unregisterRoute(route); - }); - }); - - describe('with no handler', function () { - it('throws an Error', function () { - expect(function () { - RouteStore.registerRoute(Route()); - }).toThrow(Error); - }); - }); - - describe('with no path or name', function () { - it('uses / as its path', function () { - var route = Route({ handler: App }); - RouteStore.registerRoute(route); - expect(route.props.path).toEqual('/'); - RouteStore.unregisterRoute(route); - }); - - describe('that is nested inside another route', function () { - it('uses the parent\'s path', function () { - var child; - var route = Route({ name: 'home', handler: App }, - child = Route({ handler: App }) - ); - - RouteStore.registerRoute(route); - expect(child.props.path).toEqual(route.props.path); - RouteStore.unregisterRoute(route); - }); - }); - }); - - describe('with a name but no path', function () { - it('uses its name as its path', function () { - var route = Route({ name: 'users', handler: App }); - RouteStore.registerRoute(route); - expect(route.props.path).toEqual('/users'); - RouteStore.unregisterRoute(route); - }); - }); - - describe('with the same name as another route', function () { - beforeEach(function () { - RouteStore.registerRoute(Route({ name: 'users', handler: App })); - }); - - it('throws an error', function () { - expect(function () { - RouteStore.registerRoute(Route({ name: 'users', handler: App })); - }).toThrow(Error); - }); - }); - - describe('that is missing a parameter its parent route needs', function () { - it('throws an error', function () { - expect(function () { - var childRoute; - var route = Route({ path: '/users/:userID' }, - childRoute = Route({ path: '/users/:id/comments '}) - ); - RouteStore.registerRoute(childRoute); - }).toThrow(Error); - }); - }); -}); diff --git a/tests.js b/tests.js index a4250f0e67..f23d33ce44 100644 --- a/tests.js +++ b/tests.js @@ -1,6 +1,7 @@ require('./modules/components/__tests__/DefaultRoute-test'); require('./modules/components/__tests__/NotFoundRoute-test'); require('./modules/mixins/__tests__/ActiveDelegate-test'); +require('./modules/mixins/__tests__/AsyncState-test'); require('./modules/mixins/__tests__/PathDelegate-test'); require('./modules/mixins/__tests__/PathState-test'); require('./modules/mixins/__tests__/RouteContainer-test'); From 8113a0b6b52fb0de9fdc212e968247a724d5d346 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Fri, 3 Oct 2014 20:21:18 -0700 Subject: [PATCH 10/14] Rename initialState => initialActiveState --- modules/mixins/ActiveDelegate.js | 12 ++++++------ modules/mixins/__tests__/ActiveDelegate-test.js | 6 +++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/modules/mixins/ActiveDelegate.js b/modules/mixins/ActiveDelegate.js index 52d9d2bfdc..dd94bf5a0d 100644 --- a/modules/mixins/ActiveDelegate.js +++ b/modules/mixins/ActiveDelegate.js @@ -42,22 +42,22 @@ var ActiveDelegate = { }, propTypes: { - initialState: React.PropTypes.object + initialActiveState: React.PropTypes.object // Mainly for testing. }, getDefaultProps: function () { return { - initialState: {} + initialActiveState: {} }; }, getInitialState: function () { - var initialState = this.props.initialState; + var state = this.props.initialActiveState; return { - activeRoutes: initialState.activeRoutes || [], - activeParams: initialState.activeParams || {}, - activeQuery: initialState.activeQuery || {} + activeRoutes: state.activeRoutes || [], + activeParams: state.activeParams || {}, + activeQuery: state.activeQuery || {} }; }, diff --git a/modules/mixins/__tests__/ActiveDelegate-test.js b/modules/mixins/__tests__/ActiveDelegate-test.js index c7a1e35cce..7e49046569 100644 --- a/modules/mixins/__tests__/ActiveDelegate-test.js +++ b/modules/mixins/__tests__/ActiveDelegate-test.js @@ -23,7 +23,7 @@ describe('ActiveDelegate', function () { beforeEach(function () { component = ReactTestUtils.renderIntoDocument( App({ - initialState: { + initialActiveState: { activeRoutes: [ route ] } }) @@ -44,7 +44,7 @@ describe('ActiveDelegate', function () { beforeEach(function () { component = ReactTestUtils.renderIntoDocument( App({ - initialState: { + initialActiveState: { activeRoutes: [ route ], activeParams: { id: '123', show: 'true', variant: 456 }, activeQuery: { search: 'abc', limit: 789 } @@ -81,7 +81,7 @@ describe('ActiveDelegate', function () { beforeEach(function () { component = ReactTestUtils.renderIntoDocument( App({ - initialState: { + initialActiveState: { activeRoutes: [ route ], activeParams: { id: 123 } } From 76438f9aa3bb0f5f7be8cfadbe51c73adcf520e1 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Fri, 3 Oct 2014 20:21:35 -0700 Subject: [PATCH 11/14] Add getActiveRoute --- modules/mixins/TransitionHandler.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/modules/mixins/TransitionHandler.js b/modules/mixins/TransitionHandler.js index b8a6538a7d..288aff11e1 100644 --- a/modules/mixins/TransitionHandler.js +++ b/modules/mixins/TransitionHandler.js @@ -411,6 +411,13 @@ var TransitionHandler = { */ getHandlerProps: function () { return computeHandlerProps(this.state.matches, this.state.activeQuery); + }, + + /** + * Returns a reference to the active route handler's component instance. + */ + getActiveRoute: function () { + return this.refs.__activeRoute__; } }; From 8125a1124b289d63068a40fdb3d0ed8b11d6edce Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Fri, 3 Oct 2014 20:21:57 -0700 Subject: [PATCH 12/14] Add component tests Also, removed deprecated static props. --- modules/components/Link.js | 118 +++++++--------------- modules/components/__tests__/Link-test.js | 40 ++++++++ tests.js | 1 + 3 files changed, 76 insertions(+), 83 deletions(-) create mode 100644 modules/components/__tests__/Link-test.js diff --git a/modules/components/Link.js b/modules/components/Link.js index 1dd114d6d4..965c166bd3 100644 --- a/modules/components/Link.js +++ b/modules/components/Link.js @@ -1,10 +1,7 @@ var React = require('react'); -var warning = require('react/lib/warning'); +var merge = require('react/lib/merge'); var ActiveState = require('../mixins/ActiveState'); -var RouteLookup = require('../mixins/RouteLookup'); var Transitions = require('../mixins/Transitions'); -var withoutProperties = require('../utils/withoutProperties'); -var hasOwnProperty = require('../utils/hasOwnProperty'); function isLeftClickEvent(event) { return event.button === 0; @@ -14,21 +11,6 @@ function isModifiedEvent(event) { return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey); } -/** - * DEPRECATED: A map of component props that are reserved for use by the - * router and/or React. All other props are used as params that are - * interpolated into the link's path. - */ -var RESERVED_PROPS = { - to: true, - key: true, - className: true, - activeClassName: true, - query: true, - onClick:true, - children: true // ReactChildren -}; - /** * components are used to create an element that links to a route. * When that route is active, the link gets an "active" class name (or the @@ -36,44 +18,22 @@ var RESERVED_PROPS = { * * For example, assuming you have the following route: * - * + * * * You could use the following component to link to that route: * - * + * * * In addition to params, links may pass along query string parameters * using the `query` prop. * - * + * */ var Link = React.createClass({ displayName: 'Link', - mixins: [ ActiveState, RouteLookup, Transitions ], - - statics: { - - // TODO: Deprecate passing props as params in v1.0 - getUnreservedProps: function (props) { - var props = withoutProperties(props, RESERVED_PROPS); - warning( - Object.keys(props).length === 0, - 'Passing props for params on s is deprecated, '+ - 'please use the `params` property.' - ); - return props; - }, - - /** - * Returns a hash of URL parameters to use in this 's path. - */ - getParams: function (props) { - return props.params || Link.getUnreservedProps(props); - } - - }, + mixins: [ ActiveState, Transitions ], propTypes: { to: React.PropTypes.string.isRequired, @@ -95,71 +55,63 @@ var Link = React.createClass({ }; }, - /** - * Returns the value of the "href" attribute to use on the DOM element. - */ - getHref: function () { - return this.makeHref(this.props.to, Link.getParams(this.props), this.props.query); - }, - - /** - * Returns the value of the "class" attribute to use on the DOM element, which contains - * the value of the activeClassName property when this is active. - */ - getClassName: function () { - var className = this.props.className || ''; - - if (this.state.isActive) - return className + ' ' + this.props.activeClassName; - - return className; - }, - - componentWillReceiveProps: function (nextProps) { - var params = Link.getParams(nextProps); - + updateActiveState: function () { this.setState({ - isActive: this.isActive(nextProps.to, params, nextProps.query) + isActive: this.isActive(this.props.to, this.props.params, this.props.query) }); }, - updateActiveState: function () { + componentWillReceiveProps: function (nextProps) { this.setState({ - isActive: this.isActive(this.props.to, Link.getParams(this.props), this.props.query) + isActive: this.isActive(nextProps.to, nextProps.params, nextProps.query) }); }, handleClick: function (event) { var allowTransition = true; - var ret; + var onClickResult; if (this.props.onClick) - ret = this.props.onClick(event); + onClickResult = this.props.onClick(event); if (isModifiedEvent(event) || !isLeftClickEvent(event)) return; - if (ret === false || event.defaultPrevented === true) + if (onClickResult === false || event.defaultPrevented === true) allowTransition = false; event.preventDefault(); if (allowTransition) - this.transitionTo(this.props.to, Link.getParams(this.props), this.props.query); + this.transitionTo(this.props.to, this.props.params, this.props.query); + }, + + /** + * Returns the value of the "href" attribute to use on the DOM element. + */ + getHref: function () { + return this.makeHref(this.props.to, this.props.params, this.props.query); + }, + + /** + * Returns the value of the "class" attribute to use on the DOM element, which contains + * the value of the activeClassName property when this is active. + */ + getClassName: function () { + var className = this.props.className || ''; + + if (this.state.isActive) + className += ' ' + this.props.activeClassName; + + return className; }, render: function () { - var props = { + var props = merge(this.props, { href: this.getHref(), className: this.getClassName(), onClick: this.handleClick - }; - - // pull in props without overriding - for (var propName in this.props) { - if (hasOwnProperty(this.props, propName) && hasOwnProperty(props, propName) === false) - props[propName] = this.props[propName]; - } + }); return React.DOM.a(props, this.props.children); } diff --git a/modules/components/__tests__/Link-test.js b/modules/components/__tests__/Link-test.js new file mode 100644 index 0000000000..6ddb06c997 --- /dev/null +++ b/modules/components/__tests__/Link-test.js @@ -0,0 +1,40 @@ +var assert = require('assert'); +var expect = require('expect'); +var React = require('react/addons'); +var ReactTestUtils = React.addons.TestUtils; +var Routes = require('../Routes'); +var DefaultRoute = require('../DefaultRoute'); +var Link = require('../Link'); + +describe('A Link', function () { + describe('when its route is active', function () { + var Home = React.createClass({ + render: function () { + return Link({ ref: 'link', to: 'home', className: 'a-link', activeClassName: 'highlight' }); + } + }); + + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + Routes(null, + DefaultRoute({ name: 'home', handler: Home }) + ) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + }); + + it('is active', function () { + var linkComponent = component.getActiveRoute().refs.link; + assert(linkComponent.isActive); + }); + + it('has its active class name', function () { + var linkComponent = component.getActiveRoute().refs.link; + expect(linkComponent.getClassName()).toEqual('a-link highlight'); + }); + }); +}); diff --git a/tests.js b/tests.js index f23d33ce44..d75c8900fb 100644 --- a/tests.js +++ b/tests.js @@ -1,4 +1,5 @@ require('./modules/components/__tests__/DefaultRoute-test'); +require('./modules/components/__tests__/Link-test'); require('./modules/components/__tests__/NotFoundRoute-test'); require('./modules/mixins/__tests__/ActiveDelegate-test'); require('./modules/mixins/__tests__/AsyncState-test'); From 84630df1af2a96610d99836b6d15b796caf3d30b Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Fri, 3 Oct 2014 20:36:23 -0700 Subject: [PATCH 13/14] Add tests --- modules/components/__tests__/Link-test.js | 5 +- modules/components/__tests__/Routes-test.js | 77 +++++++++++++++++++++ tests.js | 1 + 3 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 modules/components/__tests__/Routes-test.js diff --git a/modules/components/__tests__/Link-test.js b/modules/components/__tests__/Link-test.js index 6ddb06c997..87a669930f 100644 --- a/modules/components/__tests__/Link-test.js +++ b/modules/components/__tests__/Link-test.js @@ -2,8 +2,9 @@ var assert = require('assert'); var expect = require('expect'); var React = require('react/addons'); var ReactTestUtils = React.addons.TestUtils; -var Routes = require('../Routes'); +var PathStore = require('../../stores/PathStore'); var DefaultRoute = require('../DefaultRoute'); +var Routes = require('../Routes'); var Link = require('../Link'); describe('A Link', function () { @@ -25,6 +26,8 @@ describe('A Link', function () { afterEach(function () { React.unmountComponentAtNode(component.getDOMNode()); + // For some reason unmountComponentAtNode doesn't call componentWillUnmount :/ + PathStore.removeAllChangeListeners(); }); it('is active', function () { diff --git a/modules/components/__tests__/Routes-test.js b/modules/components/__tests__/Routes-test.js new file mode 100644 index 0000000000..a6fb0b7dbe --- /dev/null +++ b/modules/components/__tests__/Routes-test.js @@ -0,0 +1,77 @@ +var assert = require('assert'); +var expect = require('expect'); +var React = require('react/addons'); +var ReactTestUtils = React.addons.TestUtils; +var PathStore = require('../../stores/PathStore'); +var Routes = require('../Routes'); +var Route = require('../Route'); + +function getRootMatch(matches) { + return matches[matches.length - 1]; +} + +describe('A Routes', function () { + + var App = React.createClass({ + render: function () { + return null; + } + }); + + describe('that matches a URL', function () { + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + Routes(null, + Route({ handler: App }, + Route({ path: '/a/b/c', handler: App }) + ) + ) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + // For some reason unmountComponentAtNode doesn't call componentWillUnmount :/ + PathStore.removeAllChangeListeners(); + }); + + it('returns an array', function () { + var matches = component.match('/a/b/c'); + assert(matches); + expect(matches.length).toEqual(2); + + var rootMatch = getRootMatch(matches); + expect(rootMatch.params).toEqual({}); + }); + }); + + describe('that matches a URL with dynamic segments', function () { + var component; + beforeEach(function () { + component = ReactTestUtils.renderIntoDocument( + Routes(null, + Route({ handler: App }, + Route({ path: '/posts/:id/edit', handler: App }) + ) + ) + ); + }); + + afterEach(function () { + React.unmountComponentAtNode(component.getDOMNode()); + // For some reason unmountComponentAtNode doesn't call componentWillUnmount :/ + PathStore.removeAllChangeListeners(); + }); + + it('returns an array with the correct params', function () { + var matches = component.match('/posts/abc/edit'); + assert(matches); + expect(matches.length).toEqual(2); + + var rootMatch = getRootMatch(matches); + expect(rootMatch.params).toEqual({ id: 'abc' }); + }); + }); + +}); diff --git a/tests.js b/tests.js index d75c8900fb..92b5d0826b 100644 --- a/tests.js +++ b/tests.js @@ -1,6 +1,7 @@ require('./modules/components/__tests__/DefaultRoute-test'); require('./modules/components/__tests__/Link-test'); require('./modules/components/__tests__/NotFoundRoute-test'); +require('./modules/components/__tests__/Routes-test'); require('./modules/mixins/__tests__/ActiveDelegate-test'); require('./modules/mixins/__tests__/AsyncState-test'); require('./modules/mixins/__tests__/PathDelegate-test'); From 77ba56614050a61caae3bb8463d9fd0b555b8aa4 Mon Sep 17 00:00:00 2001 From: Michael Jackson Date: Fri, 3 Oct 2014 20:42:57 -0700 Subject: [PATCH 14/14] More tests, finished porting --- .../components/__tests__/DefaultRoute-test.js | 7 +- .../__tests__/NotFoundRoute-test.js | 7 +- modules/components/__tests__/Routes-test.js | 63 +++++++++- specs/Route.spec.js | 118 ------------------ specs/Routes.spec.js | 60 --------- specs/helper.js | 30 ----- specs/main.js | 9 -- 7 files changed, 69 insertions(+), 225 deletions(-) delete mode 100644 specs/Route.spec.js delete mode 100644 specs/Routes.spec.js delete mode 100644 specs/helper.js delete mode 100644 specs/main.js diff --git a/modules/components/__tests__/DefaultRoute-test.js b/modules/components/__tests__/DefaultRoute-test.js index 3ba72c4b67..a5bb6ffd9e 100644 --- a/modules/components/__tests__/DefaultRoute-test.js +++ b/modules/components/__tests__/DefaultRoute-test.js @@ -8,6 +8,11 @@ var PathStore = require('../../stores/PathStore'); var Route = require('../Route'); var DefaultRoute = require('../DefaultRoute'); +afterEach(function () { + // For some reason unmountComponentAtNode doesn't call componentWillUnmount :/ + PathStore.removeAllChangeListeners(); +}); + describe('A DefaultRoute', function () { it('has a null path', function () { expect(DefaultRoute({ path: '/' }).props.path).toBe(null); @@ -85,8 +90,6 @@ describe('when no child routes match a URL, but the parent\'s path matches', fun afterEach(function () { React.unmountComponentAtNode(component.getDOMNode()); - // For some reason unmountComponentAtNode doesn't call componentWillUnmount :/ - PathStore.removeAllChangeListeners(); }); it('matches the default route', function () { diff --git a/modules/components/__tests__/NotFoundRoute-test.js b/modules/components/__tests__/NotFoundRoute-test.js index 6ceb9405fc..a75e7f8d96 100644 --- a/modules/components/__tests__/NotFoundRoute-test.js +++ b/modules/components/__tests__/NotFoundRoute-test.js @@ -8,6 +8,11 @@ var PathStore = require('../../stores/PathStore'); var Route = require('../Route'); var NotFoundRoute = require('../NotFoundRoute'); +afterEach(function () { + // For some reason unmountComponentAtNode doesn't call componentWillUnmount :/ + PathStore.removeAllChangeListeners(); +}); + describe('A NotFoundRoute', function () { it('has a null path', function () { expect(NotFoundRoute({ path: '/' }).props.path).toBe(null); @@ -85,8 +90,6 @@ describe('when no child routes match a URL, but the beginning of the parent\'s p afterEach(function () { React.unmountComponentAtNode(component.getDOMNode()); - // For some reason unmountComponentAtNode doesn't call componentWillUnmount :/ - PathStore.removeAllChangeListeners(); }); it('matches the NotFoundRoute', function () { diff --git a/modules/components/__tests__/Routes-test.js b/modules/components/__tests__/Routes-test.js index a6fb0b7dbe..28db6bf4be 100644 --- a/modules/components/__tests__/Routes-test.js +++ b/modules/components/__tests__/Routes-test.js @@ -10,6 +10,11 @@ function getRootMatch(matches) { return matches[matches.length - 1]; } +afterEach(function () { + // For some reason unmountComponentAtNode doesn't call componentWillUnmount :/ + PathStore.removeAllChangeListeners(); +}); + describe('A Routes', function () { var App = React.createClass({ @@ -32,8 +37,6 @@ describe('A Routes', function () { afterEach(function () { React.unmountComponentAtNode(component.getDOMNode()); - // For some reason unmountComponentAtNode doesn't call componentWillUnmount :/ - PathStore.removeAllChangeListeners(); }); it('returns an array', function () { @@ -60,8 +63,6 @@ describe('A Routes', function () { afterEach(function () { React.unmountComponentAtNode(component.getDOMNode()); - // For some reason unmountComponentAtNode doesn't call componentWillUnmount :/ - PathStore.removeAllChangeListeners(); }); it('returns an array with the correct params', function () { @@ -74,4 +75,58 @@ describe('A Routes', function () { }); }); + + describe('when a transition is aborted', function () { + it('triggers onAbortedTransition', function (done) { + var App = React.createClass({ + statics: { + willTransitionTo: function (transition) { + transition.abort(); + } + }, + render: function () { + return React.DOM.div(); + } + }); + + function handleAbortedTransition(transition) { + assert(transition); + done(); + } + + ReactTestUtils.renderIntoDocument( + Routes({ onAbortedTransition: handleAbortedTransition }, + Route({ handler: App }) + ) + ); + }); + }); + + describe('when there is an error in a transition hook', function () { + it('triggers onTransitionError', function (done) { + var App = React.createClass({ + statics: { + willTransitionTo: function (transition) { + throw new Error('boom!'); + } + }, + render: function () { + return React.DOM.div(); + } + }); + + function handleTransitionError(error) { + assert(error); + expect(error.message).toEqual('boom!'); + done(); + } + + ReactTestUtils.renderIntoDocument( + Routes({ onTransitionError: handleTransitionError }, + Route({ handler: App }) + ) + ); + }); + }); + }); diff --git a/specs/Route.spec.js b/specs/Route.spec.js deleted file mode 100644 index 61361a2d8b..0000000000 --- a/specs/Route.spec.js +++ /dev/null @@ -1,118 +0,0 @@ -require('./helper'); -var Route = require('../modules/components/Route'); -var Routes = require('../modules/components/Routes'); - -var App = React.createClass({ - displayName: 'App', - render: function () { - return React.DOM.div(); - } -}); - -describe('a Route', function () { - - describe('that matches the URL', function () { - it('returns an array', function () { - var routes = ReactTestUtils.renderIntoDocument( - Routes(null, - Route({ handler: App }, - Route({ path: '/a/b/c', handler: App }) - ) - ) - ); - - var matches = routes.match('/a/b/c'); - assert(matches); - expect(matches.length).toEqual(2); - - var rootMatch = getRootMatch(matches); - expect(rootMatch.params).toEqual({}); - }); - - describe('that contains dynamic segments', function () { - it('returns an array with the correct params', function () { - var routes = ReactTestUtils.renderIntoDocument( - Routes(null, - Route({ handler: App }, - Route({ path: '/posts/:id/edit', handler: App }) - ) - ) - ); - - var matches = routes.match('/posts/abc/edit'); - assert(matches); - expect(matches.length).toEqual(2); - - var rootMatch = getRootMatch(matches); - expect(rootMatch.params).toEqual({ id: 'abc' }); - }); - }); - }); - - describe('that does not match the URL', function () { - it('returns null', function () { - var routes = ReactTestUtils.renderIntoDocument( - Routes(null, - Route({ handler: App }, - Route({ path: '/a/b/c', handler: App }) - ) - ) - ); - - expect(routes.match('/not-found')).toBe(null); - }); - }); - -}); - -describe('a nested Route that matches the URL', function () { - it('returns the appropriate params for each match', function () { - var routes = ReactTestUtils.renderIntoDocument( - Routes(null, - Route({ handler: App }, - Route({ name: 'posts', path: '/posts/:id', handler: App }, - Route({ name: 'comment', path: '/posts/:id/comments/:commentID', handler: App }) - ) - ) - ) - ); - - var matches = routes.match('/posts/abc/comments/123'); - assert(matches); - expect(matches.length).toEqual(3); - - var rootMatch = getRootMatch(matches); - expect(rootMatch.route.props.name).toEqual('comment'); - expect(rootMatch.params).toEqual({ id: 'abc', commentID: '123' }); - - var postsMatch = matches[1]; - expect(postsMatch.route.props.name).toEqual('posts'); - expect(postsMatch.params).toEqual({ id: 'abc' }); - }); -}); - -describe('multiple nested Routes that match the URL', function () { - it('returns the first one in the subtree, depth-first', function () { - var routes = ReactTestUtils.renderIntoDocument( - Routes(null, - Route({ handler: App }, - Route({ path: '/a', handler: App }, - Route({ path: '/a/b', name: 'expected', handler: App }) - ), - Route({ path: '/a/b', handler: App }) - ) - ) - ); - - var matches = routes.match('/a/b'); - assert(matches); - expect(matches.length).toEqual(3); - - var rootMatch = getRootMatch(matches); - expect(rootMatch.route.props.name).toEqual('expected'); - }); -}); - -function getRootMatch(matches) { - return matches[matches.length - 1]; -} diff --git a/specs/Routes.spec.js b/specs/Routes.spec.js deleted file mode 100644 index bd2ce08be8..0000000000 --- a/specs/Routes.spec.js +++ /dev/null @@ -1,60 +0,0 @@ -require('./helper'); -var Route = require('../modules/components/Route'); -var Routes = require('../modules/components/Routes'); - -describe('a Routes', function () { - - describe('when a transition is aborted', function () { - it('triggers onAbortedTransition', function (done) { - var App = React.createClass({ - statics: { - willTransitionTo: function (transition) { - transition.abort(); - } - }, - render: function () { - return React.DOM.div(); - } - }); - - function handleAbortedTransition(transition) { - assert(transition); - done(); - } - - var routes = ReactTestUtils.renderIntoDocument( - Routes({ onAbortedTransition: handleAbortedTransition }, - Route({ handler: App }) - ) - ); - }); - }); - - describe('when there is an error in a transition hook', function () { - it('triggers onTransitionError', function (done) { - var App = React.createClass({ - statics: { - willTransitionTo: function (transition) { - throw new Error('boom!'); - } - }, - render: function () { - return React.DOM.div(); - } - }); - - function handleTransitionError(error) { - assert(error); - expect(error.message).toEqual('boom!'); - done(); - } - - var routes = ReactTestUtils.renderIntoDocument( - Routes({ onTransitionError: handleTransitionError }, - Route({ handler: App }) - ) - ); - }); - }); - -}); diff --git a/specs/helper.js b/specs/helper.js deleted file mode 100644 index 6b35efd789..0000000000 --- a/specs/helper.js +++ /dev/null @@ -1,30 +0,0 @@ -assert = require('assert'); -expect = require('expect'); -React = require('react/addons'); -ReactTestUtils = React.addons.TestUtils; - -refute = function (condition, message) { - assert(!condition, message); -}; - -var RouteStore = require('../modules/stores/RouteStore'); - -beforeEach(function () { - RouteStore.unregisterAllRoutes(); -}); - -var MemoryLocation = require('../modules/locations/MemoryLocation'); -var ScrollToTopStrategy = require('../modules/strategies/ScrollToTopStrategy'); -var LocationActions = require('../modules/actions/LocationActions'); -var ScrollStore = require('../modules/stores/ScrollStore'); - -beforeEach(function () { - ScrollStore.setup(ScrollToTopStrategy); - LocationActions.setup(MemoryLocation); - LocationActions.transitionTo('/'); -}); - -afterEach(function () { - ScrollStore.teardown(); - LocationActions.teardown(); -}); diff --git a/specs/main.js b/specs/main.js deleted file mode 100644 index c5865e73c4..0000000000 --- a/specs/main.js +++ /dev/null @@ -1,9 +0,0 @@ -require('./ActiveDelegate.spec.js'); -require('./AsyncState.spec.js'); -require('./DefaultRoute.spec.js'); -require('./NotFoundRoute.spec.js'); -require('./Path.spec.js'); -require('./PathStore.spec.js'); -require('./Route.spec.js'); -require('./Routes.spec.js'); -require('./RouteStore.spec.js');