From 31be677e29fed74c861cefd34d1a03a5adabab31 Mon Sep 17 00:00:00 2001 From: "A. Speller" Date: Mon, 9 Sep 2013 21:56:38 +0100 Subject: [PATCH] Query params implementation --- packages/ember-routing/lib/helpers/link_to.js | 59 ++- packages/ember-routing/lib/system/dsl.js | 17 +- packages/ember-routing/lib/system/route.js | 17 +- packages/ember-routing/lib/system/router.js | 12 +- .../lib/vendor/route-recognizer.js | 109 ++++- packages/ember-routing/lib/vendor/router.js | 306 ++++++++++-- packages/ember/tests/helpers/link_to_test.js | 221 ++++++++- .../ember/tests/routing/query_params_test.js | 449 ++++++++++++++++++ 8 files changed, 1120 insertions(+), 70 deletions(-) create mode 100644 packages/ember/tests/routing/query_params_test.js diff --git a/packages/ember-routing/lib/helpers/link_to.js b/packages/ember-routing/lib/helpers/link_to.js index 873e0c4ee9e..9fcdfa18272 100644 --- a/packages/ember-routing/lib/helpers/link_to.js +++ b/packages/ember-routing/lib/helpers/link_to.js @@ -197,19 +197,40 @@ Ember.onLoad('Ember.Handlebars', function(Handlebars) { Ember.Handlebars.normalizePath(templateContext, path, helperParameters.options.data); this.registerObserver(normalizedPath.root, normalizedPath.path, this, this._paramsChanged); } + + + if (Ember.FEATURES.isEnabled("query-params")) { + var queryParams = get(this, '_potentialQueryParams') || []; + + for(i=0; i < queryParams.length; i++) { + this.registerObserver(this, queryParams[i], this, this._queryParamsChanged); + } + } }, /** @private This method is invoked by observers installed during `init` that fire - whenever the helpers + whenever the params change @method _paramsChanged */ _paramsChanged: function() { this.notifyPropertyChange('resolvedParams'); }, + + /** + @private + + This method is invoked by observers installed during `init` that fire + whenever the query params change + */ + _queryParamsChanged: function (object, path) { + this.notifyPropertyChange('queryParams'); + }, + + /** @private @@ -345,7 +366,6 @@ Ember.onLoad('Ember.Handlebars', function(Handlebars) { @return {Array} An array with the route name and any dynamic segments */ routeArgs: Ember.computed(function() { - var resolvedParams = get(this, 'resolvedParams').slice(0), router = get(this, 'router'), namedRoute = resolvedParams[0]; @@ -365,9 +385,44 @@ Ember.onLoad('Ember.Handlebars', function(Handlebars) { } } + if (Ember.FEATURES.isEnabled("query-params")) { + var queryParams = get(this, 'queryParams'); + + if (queryParams || queryParams === false) { resolvedParams.push({queryParams: queryParams}); } + } + return resolvedParams; + }).property('resolvedParams', 'queryParams', 'router.url'), + + + _potentialQueryParams: Ember.computed(function () { + var namedRoute = get(this, 'resolvedParams')[0]; + if (!namedRoute) { return null; } + var router = get(this, 'router'); + + namedRoute = fullRouteName(router, namedRoute); + + return router.router.queryParamsForHandler(namedRoute); }).property('resolvedParams'), + queryParams: Ember.computed(function () { + var self = this, + queryParams = null, + allowedQueryParams = get(this, '_potentialQueryParams'); + + if (!allowedQueryParams) { return null; } + allowedQueryParams.forEach(function (param) { + var value = get(self, param); + if (typeof value !== 'undefined') { + queryParams = queryParams || {}; + queryParams[param] = value; + } + }); + + + return queryParams; + }).property('_potentialQueryParams.[]'), + /** Sets the element's `href` attribute to the url for the `LinkView`'s targeted route. diff --git a/packages/ember-routing/lib/system/dsl.js b/packages/ember-routing/lib/system/dsl.js index 6d9f333fcf7..fe850e87ac2 100644 --- a/packages/ember-routing/lib/system/dsl.js +++ b/packages/ember-routing/lib/system/dsl.js @@ -26,17 +26,17 @@ DSL.prototype = { if (callback) { var dsl = new DSL(name); callback.call(dsl); - this.push(options.path, name, dsl.generate()); + this.push(options.path, name, dsl.generate(), options.queryParams); } else { - this.push(options.path, name); + this.push(options.path, name, null, options.queryParams); } }, - push: function(url, name, callback) { + push: function(url, name, callback, queryParams) { var parts = name.split('.'); if (url === "" || url === "/" || parts[parts.length-1] === "index") { this.explicitIndex = true; } - this.matches.push([url, name, callback]); + this.matches.push([url, name, callback, queryParams]); }, route: function(name, options) { @@ -52,7 +52,7 @@ DSL.prototype = { name = this.parent + "." + name; } - this.push(options.path, name); + this.push(options.path, name, null, options.queryParams); }, generate: function() { @@ -65,7 +65,12 @@ DSL.prototype = { return function(match) { for (var i=0, l=dslMatches.length; i 0) { + currentResult.queryParams = activeQueryParams; + } + result.push(currentResult); } return result; @@ -317,7 +329,11 @@ define("route-recognizer", regex += segment.regex(); } - handlers.push({ handler: route.handler, names: names }); + var handler = { handler: route.handler, names: names }; + if(route.queryParams) { + handler.queryParams = route.queryParams; + } + handlers.push(handler); } if (isEmpty) { @@ -369,12 +385,61 @@ define("route-recognizer", if (output.charAt(0) !== '/') { output = '/' + output; } + if (params && params.queryParams) { + output += this.generateQueryString(params.queryParams, route.handlers); + } + return output; }, + generateQueryString: function(params, handlers) { + var pairs = [], allowedParams = []; + for(var i=0; i < handlers.length; i++) { + var currentParamList = handlers[i].queryParams; + if(currentParamList) { + allowedParams.push.apply(allowedParams, currentParamList); + } + } + for(var key in params) { + if (params.hasOwnProperty(key)) { + if(!~allowedParams.indexOf(key)) { + throw 'Query param "' + key + '" is not specified as a valid param for this route'; + } + var value = params[key]; + var pair = encodeURIComponent(key); + if(value !== true) { + pair += "=" + encodeURIComponent(value); + } + pairs.push(pair); + } + } + + if (pairs.length === 0) { return ''; } + + return "?" + pairs.join("&"); + }, + + parseQueryString: function(queryString) { + var pairs = queryString.split("&"), queryParams = {}; + for(var i=0; i < pairs.length; i++) { + var pair = pairs[i].split('='), + key = decodeURIComponent(pair[0]), + value = pair[1] ? decodeURIComponent(pair[1]) : true; + queryParams[key] = value; + } + return queryParams; + }, + recognize: function(path) { var states = [ this.rootState ], - pathLen, i, l; + pathLen, i, l, queryStart, queryParams = {}; + + queryStart = path.indexOf('?'); + if (~queryStart) { + var queryString = path.substr(queryStart + 1, path.length); + path = path.substr(0, queryStart); + queryParams = this.parseQueryString(queryString); + } // DEBUG GROUP path @@ -402,7 +467,7 @@ define("route-recognizer", var state = solutions[0]; if (state && state.handlers) { - return findHandler(state, path); + return findHandler(state, path, queryParams); } } }; @@ -427,12 +492,25 @@ define("route-recognizer", if (callback.length === 0) { throw new Error("You must have an argument in the function passed to `to`"); } this.matcher.addChild(this.path, target, callback, this.delegate); } + return this; + }, + + withQueryParams: function() { + if (arguments.length === 0) { throw new Error("you must provide arguments to the withQueryParams method"); } + for (var i = 0; i < arguments.length; i++) { + if (typeof arguments[i] !== "string") { + throw new Error('you should call withQueryParams with a list of strings, e.g. withQueryParams("foo", "bar")'); + } + } + var queryParams = [].slice.call(arguments); + this.matcher.addQueryParams(this.path, queryParams); } }; function Matcher(target) { this.routes = {}; this.children = {}; + this.queryParams = {}; this.target = target; } @@ -441,6 +519,10 @@ define("route-recognizer", this.routes[path] = handler; }, + addQueryParams: function(path, params) { + this.queryParams[path] = params; + }, + addChild: function(path, target, callback, delegate) { var matcher = new Matcher(target); this.children[path] = matcher; @@ -467,23 +549,26 @@ define("route-recognizer", }; } - function addRoute(routeArray, path, handler) { + function addRoute(routeArray, path, handler, queryParams) { var len = 0; for (var i=0, l=routeArray.length; i 0) { + var err = 'You supplied the params '; + err += missingParams.map(function(param) { + return '"' + param + "=" + queryParams[param] + '"'; + }).join(' and '); + + err += ' which are not valid for the "' + handlerName + '" handler or its parents'; + + throw new Error(err); + } + return this.recognizer.generate(handlerName, params); }, isActive: function(handlerName) { - var contexts = slice.call(arguments, 1); + var partitionedArgs = extractQueryParams(slice.call(arguments, 1)), + contexts = partitionedArgs[0], + queryParams = partitionedArgs[1], + activeQueryParams = {}, + effectiveQueryParams = {}; var targetHandlerInfos = this.targetHandlerInfos, found = false, names, object, handlerInfo, handlerObj; @@ -307,19 +353,24 @@ define("router", if (!targetHandlerInfos) { return false; } var recogHandlers = this.recognizer.handlersFor(targetHandlerInfos[targetHandlerInfos.length - 1].name); - for (var i=targetHandlerInfos.length-1; i>=0; i--) { handlerInfo = targetHandlerInfos[i]; if (handlerInfo.name === handlerName) { found = true; } if (found) { - if (contexts.length === 0) { break; } + var recogHandler = recogHandlers[i]; - if (handlerInfo.isDynamic) { + merge(activeQueryParams, handlerInfo.queryParams); + if (queryParams !== false) { + merge(effectiveQueryParams, handlerInfo.queryParams); + mergeSomeKeys(effectiveQueryParams, queryParams, recogHandler.queryParams); + } + + if (handlerInfo.isDynamic && contexts.length > 0) { object = contexts.pop(); if (isParam(object)) { - var recogHandler = recogHandlers[i], name = recogHandler.names[0]; + var name = recogHandler.names[0]; if ("" + object !== this.currentParams[name]) { return false; } } else if (handlerInfo.context !== object) { return false; @@ -328,7 +379,8 @@ define("router", } } - return contexts.length === 0 && found; + + return contexts.length === 0 && found && queryParamsEqual(activeQueryParams, effectiveQueryParams); }, trigger: function(name) { @@ -351,7 +403,7 @@ define("router", a shared pivot parent route and other data necessary to perform a transition. */ - function getMatchPoint(router, handlers, objects, inputParams) { + function getMatchPoint(router, handlers, objects, inputParams, queryParams) { var matchPoint = handlers.length, providedModels = {}, i, @@ -406,6 +458,12 @@ define("router", } } + // If there is an old handler, see if query params are the same. If there isn't an old handler, + // hasChanged will already be true here + if(oldHandlerInfo && !queryParamsEqual(oldHandlerInfo.queryParams, handlerObj.queryParams)) { + hasChanged = true; + } + if (hasChanged) { matchPoint = i; } } @@ -439,6 +497,28 @@ define("router", return (typeof object === "string" || object instanceof String || !isNaN(object)); } + + + /** + @private + + This method takes a handler name and returns a list of query params + that are valid to pass to the handler or its parents + + @param {Router} router + @param {String} handlerName + @return {Array[String]} a list of query parameters + */ + function queryParamsForHandler(router, handlerName) { + var handlers = router.recognizer.handlersFor(handlerName), + queryParams = []; + + for (var i = 0; i < handlers.length; i++) { + queryParams.push.apply(queryParams, handlers[i].queryParams || []); + } + + return queryParams; + } /** @private @@ -450,13 +530,17 @@ define("router", @param {Array[Object]} objects @return {Object} a serialized parameter hash */ - function paramsForHandler(router, handlerName, objects) { + function paramsForHandler(router, handlerName, objects, queryParams) { var handlers = router.recognizer.handlersFor(handlerName), params = {}, - matchPoint = getMatchPoint(router, handlers, objects).matchPoint, + handlerInfos = generateHandlerInfosWithQueryParams(router, handlers, queryParams), + matchPoint = getMatchPoint(router, handlerInfos, objects).matchPoint, + mergedQueryParams = {}, object, handlerObj, handler, names, i; + params.queryParams = {}; + for (i=0; i 0) { + handlerInfo.queryParams = activeQueryParams; + } + + handlerInfos.push(handlerInfo); + } + + return handlerInfos; + } + + /** + @private + */ + function createQueryParamTransition(router, queryParams) { + var currentHandlers = router.currentHandlerInfos, + currentHandler = currentHandlers[currentHandlers.length - 1], + name = currentHandler.name; + + log(router, "Attempting query param transition"); + + return createNamedTransition(router, [name, queryParams]); + } + /** @private */ function createNamedTransition(router, args) { - var handlers = router.recognizer.handlersFor(args[0]); + var partitionedArgs = extractQueryParams(args), + pureArgs = partitionedArgs[0], + queryParams = partitionedArgs[1], + handlers = router.recognizer.handlersFor(pureArgs[0]), + handlerInfos = generateHandlerInfosWithQueryParams(router, handlers, queryParams); + - log(router, "Attempting transition to " + args[0]); + log(router, "Attempting transition to " + pureArgs[0]); - return performTransition(router, handlers, slice.call(args, 1), router.currentParams); + return performTransition(router, handlerInfos, slice.call(pureArgs, 1), router.currentParams, queryParams); } /** @private */ function createURLTransition(router, url) { - var results = router.recognizer.recognize(url), - currentHandlerInfos = router.currentHandlerInfos; + currentHandlerInfos = router.currentHandlerInfos, + queryParams = {}; log(router, "Attempting URL transition to " + url); @@ -510,7 +660,11 @@ define("router", return errorTransition(router, new Router.UnrecognizedURLError(url)); } - return performTransition(router, results, [], {}); + for(var i = 0; i < results.length; i++) { + merge(queryParams, results[i].queryParams); + } + + return performTransition(router, results, [], {}, queryParams); } @@ -594,8 +748,9 @@ define("router", checkAbort(transition); setContext(handler, context); + setQueryParams(handler, handlerInfo.queryParams); - if (handler.setup) { handler.setup(context); } + if (handler.setup) { handler.setup(context, handlerInfo.queryParams); } checkAbort(transition); } catch(e) { if (!(e instanceof Router.TransitionAborted)) { @@ -626,6 +781,29 @@ define("router", } } + /** + @private + + determines if two queryparam objects are the same or not + **/ + function queryParamsEqual(a, b) { + a = a || {}; + b = b || {}; + var checkedKeys = [], key; + for(key in a) { + if (!a.hasOwnProperty(key)) { continue; } + if(b[key] !== a[key]) { return false; } + checkedKeys.push(key); + } + for(key in b) { + if (!b.hasOwnProperty(key)) { continue; } + if (~checkedKeys.indexOf(key)) { continue; } + // b has a key not in a + return false; + } + return true; + } + /** @private @@ -675,19 +853,21 @@ define("router", unchanged: [] }; - var handlerChanged, contextChanged, i, l; + var handlerChanged, contextChanged, queryParamsChanged, i, l; for (i=0, l=newHandlers.length; i 0 && array[len - 1] && array[len - 1].hasOwnProperty('queryParams')) { + queryParams = array[len - 1].queryParams; + head = slice.call(array, 0, len - 1); + return [head, queryParams]; + } else { + return [array, null]; + } + } + /** @private Creates, begins, and returns a Transition. */ - function performTransition(router, recogHandlers, providedModelsArray, params, data) { + function performTransition(router, recogHandlers, providedModelsArray, params, queryParams, data) { - var matchPointResults = getMatchPoint(router, recogHandlers, providedModelsArray, params), + var matchPointResults = getMatchPoint(router, recogHandlers, providedModelsArray, params, queryParams), targetName = recogHandlers[recogHandlers.length - 1].handler, wasTransitioning = false, currentHandlerInfos = router.currentHandlerInfos; // Check if there's already a transition underway. if (router.activeTransition) { - if (transitionsIdentical(router.activeTransition, targetName, providedModelsArray)) { + if (transitionsIdentical(router.activeTransition, targetName, providedModelsArray, queryParams)) { return router.activeTransition; } router.activeTransition.abort(); @@ -769,6 +973,7 @@ define("router", transition.providedModelsArray = providedModelsArray; transition.params = matchPointResults.params; transition.data = data || {}; + transition.queryParams = queryParams; router.activeTransition = transition; var handlerInfos = generateHandlerInfos(router, recogHandlers); @@ -835,11 +1040,16 @@ define("router", var handlerObj = recogHandlers[i], isDynamic = handlerObj.isDynamic || (handlerObj.names && handlerObj.names.length); - handlerInfos.push({ + + var handlerInfo = { isDynamic: !!isDynamic, name: handlerObj.handler, handler: router.getHandler(handlerObj.handler) - }); + }; + if(handlerObj.queryParams) { + handlerInfo.queryParams = handlerObj.queryParams; + } + handlerInfos.push(handlerInfo); } return handlerInfos; } @@ -847,7 +1057,7 @@ define("router", /** @private */ - function transitionsIdentical(oldTransition, targetName, providedModelsArray) { + function transitionsIdentical(oldTransition, targetName, providedModelsArray, queryParams) { if (oldTransition.targetName !== targetName) { return false; } @@ -857,6 +1067,11 @@ define("router", for (var i = 0, len = oldModels.length; i < len; ++i) { if (oldModels[i] !== providedModelsArray[i]) { return false; } } + + if(!queryParamsEqual(oldTransition.queryParams, queryParams)) { + return false; + } + return true; } @@ -870,11 +1085,12 @@ define("router", var router = transition.router, seq = transition.sequence, - handlerName = handlerInfos[handlerInfos.length - 1].name; + handlerName = handlerInfos[handlerInfos.length - 1].name, + i; // Collect params for URL. var objects = [], providedModels = transition.providedModelsArray.slice(); - for (var i = handlerInfos.length - 1; i>=0; --i) { + for (i = handlerInfos.length - 1; i>=0; --i) { var handlerInfo = handlerInfos[i]; if (handlerInfo.isDynamic) { var providedModel = providedModels.pop(); @@ -882,10 +1098,18 @@ define("router", } } - var params = paramsForHandler(router, handlerName, objects); + var newQueryParams = {}; + for (i = handlerInfos.length - 1; i>=0; --i) { + merge(newQueryParams, handlerInfos[i].queryParams); + } + router.currentQueryParams = newQueryParams; + + + var params = paramsForHandler(router, handlerName, objects, transition.queryParams); router.currentParams = params; + var urlMethod = transition.urlMethod; if (urlMethod) { var url = router.recognizer.generate(handlerName, params); @@ -976,13 +1200,13 @@ define("router", log(router, seq, handlerName + ": calling beforeModel hook"); - var p = handler.beforeModel && handler.beforeModel(transition); + var args = [transition, handlerInfo.queryParams], + p = handler.beforeModel && handler.beforeModel.apply(handler, args); return (p instanceof Transition) ? null : p; } function model() { log(router, seq, handlerName + ": resolving model"); - var p = getModel(handlerInfo, transition, handlerParams[handlerName], index >= matchPoint); return (p instanceof Transition) ? null : p; } @@ -997,7 +1221,8 @@ define("router", transition.resolvedModels[handlerInfo.name] = context; - var p = handler.afterModel && handler.afterModel(context, transition); + var args= [context, transition, handlerInfo.queryParams], + p = handler.afterModel && handler.afterModel.apply(handler, args); return (p instanceof Transition) ? null : p; } @@ -1028,9 +1253,8 @@ define("router", or use one of the models provided to `transitionTo`. */ function getModel(handlerInfo, transition, handlerParams, needsUpdate) { - var handler = handlerInfo.handler, - handlerName = handlerInfo.name; + handlerName = handlerInfo.name, args; if (!needsUpdate && handler.hasOwnProperty('context')) { return handler.context; @@ -1041,7 +1265,9 @@ define("router", return typeof providedModel === 'function' ? providedModel() : providedModel; } - return handler.model && handler.model(handlerParams || {}, transition); + args = [handlerParams || {}, transition, handlerInfo.queryParams]; + + return handler.model && handler.model.apply(handler, args); } /** @@ -1074,10 +1300,12 @@ define("router", // Normalize blank transitions to root URL transitions. var name = args[0] || '/'; - if (name.charAt(0) === '/') { + if(args.length === 1 && args[0].hasOwnProperty('queryParams')) { + return createQueryParamTransition(router, args[0]); + } else if (name.charAt(0) === '/') { return createURLTransition(router, name); } else { - return createNamedTransition(router, args); + return createNamedTransition(router, slice.call(args)); } } diff --git a/packages/ember/tests/helpers/link_to_test.js b/packages/ember/tests/helpers/link_to_test.js index 4bdfafc7b9b..a97065494df 100644 --- a/packages/ember/tests/helpers/link_to_test.js +++ b/packages/ember/tests/helpers/link_to_test.js @@ -15,6 +15,20 @@ function compile(template) { return Ember.Handlebars.compile(template); } +function shouldNotBeActive(selector) { + checkActive(selector, false); +} + +function shouldBeActive(selector) { + checkActive(selector, true); +} + +function checkActive(selector, active) { + var classList = Ember.$(selector, '#qunit-fixture')[0].classList; + equal(classList.contains('active'), active, selector + " active should be " + active.toString()); + +} + module("The {{link-to}} helper", { setup: function() { Ember.run(function() { @@ -442,9 +456,10 @@ test("The {{link-to}} helper accepts string/numeric arguments", function() { }); test("The {{link-to}} helper unwraps controllers", function() { - // The serialize hook is called twice: once to generate the href for the - // link and once to generate the URL when the link is clicked. - expect(2); + // The serialize hook is called thrice: once to generate the href for the + // link, once to generate the URL when the link is clicked, and again + // when the URL changes to check if query params have been updated + expect(3); Router.map(function() { this.route('filter', { path: '/filters/:filter' }); @@ -652,6 +667,206 @@ test("The {{link-to}} helper refreshes href element when one of params changes", equal(Ember.$('#post', '#qunit-fixture').attr('href'), '#', 'href attr becomes # when one of the arguments in nullified'); }); +if (Ember.FEATURES.isEnabled("query-params")) { + test("The {{linkTo}} helper supports query params", function() { + expect(66); + + Router.map(function() { + this.route("about", {queryParams: ['section']}); + this.resource("items", { queryParams: ['sort', 'direction'] }); + }); + + Ember.TEMPLATES.about = Ember.Handlebars.compile("

About

{{#linkTo 'about' id='about-link'}}About{{/linkTo}} {{#linkTo 'about' section='intro' id='about-link-with-qp'}}Intro{{/linkTo}}{{#linkTo 'about' section=false id='about-clear-qp'}}Intro{{/linkTo}}{{#if isIntro}}

Here is the intro

{{/if}}"); + Ember.TEMPLATES.items = Ember.Handlebars.compile("

Items

{{#linkTo 'about' id='about-link'}}About{{/linkTo}} {{#linkTo 'items' id='items-link' directionBinding=otherDirection}}Sort{{/linkTo}} {{#linkTo 'items' id='items-sort-link' sort='name'}}Sort Ascending{{/linkTo}} {{#linkTo 'items' id='items-clear-link' queryParams=false}}Clear Query Params{{/linkTo}}"); + + App.AboutRoute = Ember.Route.extend({ + setupController: function(controller, context, queryParams) { + controller.set('isIntro', queryParams.section === 'intro'); + } + }); + + App.ItemsRoute = Ember.Route.extend({ + setupController: function (controller, context, queryParams) { + controller.set('currentDirection', queryParams.direction || 'asc'); + } + }); + + var shouldNotHappen = function(error) { + console.error(error.stack); + ok(false, "this .then handler should not be called: " + error.message); + }; + + App.ItemsController = Ember.Controller.extend({ + currentDirection: 'asc', + otherDirection: Ember.computed(function () { + if (get(this, 'currentDirection') === 'asc') { + return 'desc'; + } else { + return 'asc'; + } + }).property('currentDirection') + }); + + bootApplication(); + + Ember.run(function() { + router.handleURL("/about"); + }); + + equal(Ember.$('h1:contains(About)', '#qunit-fixture').length, 1, "The about template was rendered"); + equal(normalizeUrl(Ember.$('#about-link').attr('href')), '/about', "The about link points back at /about"); + shouldBeActive('#about-link'); + equal(normalizeUrl(Ember.$('#about-link-with-qp').attr('href')), '/about?section=intro', "The helper accepts query params"); + shouldNotBeActive('#about-link-with-qp'); + equal(normalizeUrl(Ember.$('#about-clear-qp').attr('href')), '/about', "Falsy query params work"); + shouldBeActive('#about-clear-qp'); + + + Ember.run(function() { + Ember.$('#about-link-with-qp', '#qunit-fixture').click(); + }); + + equal(router.get('url'), "/about?section=intro", "Clicking linkTo updates the url"); + equal(Ember.$('p', '#qunit-fixture').text(), "Here is the intro", "Query param is applied to controller"); + equal(normalizeUrl(Ember.$('#about-link').attr('href')), '/about?section=intro', "The params have stuck"); + shouldBeActive('#about-link'); + equal(normalizeUrl(Ember.$('#about-link-with-qp').attr('href')), '/about?section=intro', "The helper accepts query params"); + shouldBeActive('#about-link-with-qp'); + equal(normalizeUrl(Ember.$('#about-clear-qp').attr('href')), '/about', "Falsy query params clear querystring"); + shouldNotBeActive('#about-clear-qp'); + + + Ember.run(function() { + router.transitionTo("/about"); + }); + + equal(router.get('url'), "/about", "handleURL clears query params"); + + Ember.run(function() { + router.transitionTo("/items"); + }); + + var controller = container.lookup('controller:items'); + + equal(controller.get('currentDirection'), 'asc', "Current direction is asc"); + equal(controller.get('otherDirection'), 'desc', "Other direction is desc"); + + equal(Ember.$('h1:contains(Items)', '#qunit-fixture').length, 1, "The items template was rendered"); + equal(normalizeUrl(Ember.$('#about-link').attr('href')), '/about', "The params have not stuck"); + shouldNotBeActive('#about-link'); + equal(normalizeUrl(Ember.$('#items-link').attr('href')), '/items?direction=desc', "Params can come from bindings"); + shouldNotBeActive('#items-link'); + equal(normalizeUrl(Ember.$('#items-clear-link').attr('href')), '/items', "Can clear query params"); + shouldBeActive('#items-clear-link'); + + Ember.run(function() { + Ember.$('#items-link', '#qunit-fixture').click(); + }); + + equal(router.get('url'), "/items?direction=desc", "Clicking linkTo should direct to the correct url"); + equal(controller.get('currentDirection'), 'desc', "Current direction is desc"); + equal(controller.get('otherDirection'), 'asc', "Other direction is asc"); + + equal(normalizeUrl(Ember.$('#items-sort-link').attr('href')), '/items?direction=desc&sort=name', "linkTo href correctly merges query parmas"); + shouldNotBeActive('#items-sort-link'); + + equal(normalizeUrl(Ember.$('#items-clear-link').attr('href')), '/items', "Can clear query params"); + shouldNotBeActive('#items-clear-link'); + + Ember.run(function() { + Ember.$('#items-sort-link', '#qunit-fixture').click(); + }); + + + equal(router.get('url'), "/items?sort=name&direction=desc", "The params should be merged correctly"); + equal(controller.get('currentDirection'), 'desc', "Current direction is desc"); + equal(controller.get('otherDirection'), 'asc', "Other direction is asc"); + + equal(normalizeUrl(Ember.$('#items-sort-link').attr('href')), "/items?sort=name&direction=desc", "linkTo href correctly merges query parmas"); + shouldBeActive('#items-sort-link'); + + equal(normalizeUrl(Ember.$('#items-link').attr('href')), "/items?sort=name&direction=asc", "Params can come from bindings"); + shouldNotBeActive('#items-link'); + + equal(normalizeUrl(Ember.$('#items-clear-link').attr('href')), '/items', "Can clear query params"); + shouldNotBeActive('#items-clear-link'); + + Ember.run(function() { + controller.set('currentDirection', 'asc'); + }); + + equal(controller.get('currentDirection'), 'asc', "Current direction is asc"); + equal(controller.get('otherDirection'), 'desc', "Other direction is desc"); + + equal(normalizeUrl(Ember.$('#items-link').attr('href')), "/items?sort=name&direction=desc", "Params are updated when bindings change"); + shouldBeActive('#items-link'); + equal(normalizeUrl(Ember.$('#items-sort-link').attr('href')), '/items?sort=name&direction=desc', "linkTo href correctly merges query params when other params change"); + shouldBeActive('#items-sort-link'); + + Ember.run(function() { + Ember.$('#items-sort-link', '#qunit-fixture').click(); + }); + + equal(router.get('url'), '/items?sort=name&direction=desc', "Clicking the active link should preserve the url"); + shouldBeActive('#items-sort-link'); + + + var promise, next; + + stop(); + + Ember.run(function () { + promise = router.transitionTo({queryParams: {sort: false}}); + }); + + next = function () { + equal(router.get('url'), '/items?direction=desc', "Transitioning updates the url"); + + equal(controller.get('currentDirection'), 'desc', "Current direction is asc"); + equal(controller.get('otherDirection'), 'asc', "Other direction is desc"); + + equal(normalizeUrl(Ember.$('#items-link').attr('href')), "/items?direction=asc", "Params are updated when transitioning"); + shouldNotBeActive('#items-link'); + + equal(normalizeUrl(Ember.$('#items-sort-link').attr('href')), "/items?direction=desc&sort=name", "Params are updated when transitioning"); + shouldNotBeActive('#items-sort-link'); + + return router.transitionTo({queryParams: {sort: 'name'}}); + }; + + Ember.run(function () { + promise.then(next, shouldNotHappen); + }); + + next = function () { + equal(router.get('url'), '/items?sort=name&direction=desc', "Transitioning updates the url"); + + equal(controller.get('currentDirection'), 'desc', "Current direction is asc"); + equal(controller.get('otherDirection'), 'asc', "Other direction is desc"); + + equal(normalizeUrl(Ember.$('#items-link').attr('href')), "/items?sort=name&direction=asc", "Params are updated when transitioning"); + shouldNotBeActive('#items-link'); + + equal(normalizeUrl(Ember.$('#items-sort-link').attr('href')), "/items?sort=name&direction=desc", "Params are updated when transitioning"); + shouldBeActive('#items-sort-link'); + + + Ember.$('#items-clear-link', '#qunit-fixture').click(); + + equal(router.get('url'), '/items', "Link clears the query params"); + equal(normalizeUrl(Ember.$('#items-clear-link').attr('href')), '/items', "Can clear query params"); + shouldBeActive('#items-clear-link'); + + + start(); + }; + + Ember.run(function () { + promise.then(next, shouldNotHappen); + }); + }); +} + test("The {{link-to}} helper's bound parameter functionality works as expected in conjunction with an ObjectProxy/Controller", function() { Router.map(function() { this.route('post', { path: '/posts/:post_id' }); diff --git a/packages/ember/tests/routing/query_params_test.js b/packages/ember/tests/routing/query_params_test.js new file mode 100644 index 00000000000..bcc09d42300 --- /dev/null +++ b/packages/ember/tests/routing/query_params_test.js @@ -0,0 +1,449 @@ +var Router, App, AppView, templates, router, container, originalTemplates; +var get = Ember.get, set = Ember.set; + +function bootApplication(url) { + router = container.lookup('router:main'); + if(url) { router.location.setURL(url); } + Ember.run(App, 'advanceReadiness'); +} + +function compile(string) { + return Ember.Handlebars.compile(string); +} + +function handleURL(path) { + return Ember.run(function() { + return router.handleURL(path).then(function(value) { + ok(true, 'url: `' + path + '` was handled'); + return value; + }, function(reason) { + ok(false, 'failed to visit:`' + path + '` reason: `' + QUnit.jsDump.parse(reason)); + throw reason; + }); + }); +} + +function handleURLAborts(path) { + Ember.run(function() { + router.handleURL(path).then(function(value) { + ok(false, 'url: `' + path + '` was NOT to be handled'); + }, function(reason) { + ok(reason && reason.message === "TransitionAborted", 'url: `' + path + '` was to be aborted'); + }); + }); +} + +function shouldNotHappen(error) { + console.error(error.stack); + ok(false, "this .then handler should not be called: " + error.message); +} + +function handleURLRejectsWith(path, expectedReason) { + Ember.run(function() { + router.handleURL(path).then(function(value) { + ok(false, 'expected handleURLing: `' + path + '` to fail'); + }, function(reason) { + equal(expectedReason, reason); + }); + }); +} +if (Ember.FEATURES.isEnabled("query-params")) { + module("Routing with Query Params", { + setup: function() { + Ember.run(function() { + App = Ember.Application.create({ + name: "App", + rootElement: '#qunit-fixture' + }); + + App.deferReadiness(); + + App.Router.reopen({ + location: 'none' + }); + + Router = App.Router; + + App.LoadingRoute = Ember.Route.extend({ + }); + + container = App.__container__; + + originalTemplates = Ember.$.extend({}, Ember.TEMPLATES); + Ember.TEMPLATES.application = compile("{{outlet}}"); + Ember.TEMPLATES.home = compile("

Hours

"); + Ember.TEMPLATES.homepage = compile("

Megatroll

{{home}}

"); + Ember.TEMPLATES.camelot = compile('

Is a silly place

'); + }); + }, + + teardown: function() { + Ember.run(function() { + App.destroy(); + App = null; + + Ember.TEMPLATES = originalTemplates; + }); + } + }); + + test("The Homepage with Query Params", function() { + expect(5); + + Router.map(function() { + this.route("index", { path: "/", queryParams: ['foo', 'baz'] }); + }); + + App.IndexRoute = Ember.Route.extend({ + beforeModel: function(transition, queryParams) { + deepEqual(queryParams, {foo: 'bar', baz: true}, "beforeModel hook is called with query params"); + }, + + model: function(params, transition, queryParams) { + deepEqual(queryParams, {foo: 'bar', baz: true}, "Model hook is called with query params"); + }, + + afterModel: function(resolvedModel, transition, queryParams) { + deepEqual(queryParams, {foo: 'bar', baz: true}, "afterModel hook is called with query params"); + }, + + setupController: function (controller, context, queryParams) { + deepEqual(queryParams, {foo: 'bar', baz: true}, "setupController hook is called with query params"); + }, + + renderTemplate: function (controller, context, queryParams) { + deepEqual(queryParams, {foo: 'bar', baz: true}, "renderTemplates hook is called with query params"); + } + + }); + + + bootApplication("/?foo=bar&baz"); + }); + + + asyncTest("Transitioning query params works on the same route", function() { + expect(25); + + var expectedQueryParams; + + Router.map(function() { + this.route("home", { path: "/" }); + this.resource("special", { path: "/specials/:menu_item_id", queryParams: ['view'] }); + }); + + App.SpecialRoute = Ember.Route.extend({ + beforeModel: function (transition, queryParams) { + deepEqual(queryParams, expectedQueryParams, "The query params are correct in the beforeModel hook"); + }, + + model: function(params, transition, queryParams) { + deepEqual(queryParams, expectedQueryParams, "The query params are correct in the model hook"); + return {id: params.menu_item_id}; + }, + + afterModel: function (resolvedModel, transition, queryParams) { + deepEqual(queryParams, expectedQueryParams, "The query params are correct in the beforeModel hook"); + }, + + setupController: function (controller, context, queryParams) { + deepEqual(queryParams, expectedQueryParams, "The query params are correct in the setupController hook"); + }, + + renderTemplate: function (controller, context, queryParams) { + deepEqual(queryParams, expectedQueryParams, "The query params are correct in the renderTemplates hook"); + }, + + serialize: function (obj) { + return {menu_item_id: obj.id}; + } + }); + + + Ember.TEMPLATES.home = Ember.Handlebars.compile( + "

Home

" + ); + + + Ember.TEMPLATES.special = Ember.Handlebars.compile( + "

{{content.id}}

" + ); + + bootApplication(); + + var transition = handleURL('/'); + + Ember.run(function() { + transition.then(function() { + equal(Ember.$('h3', '#qunit-fixture').text(), "Home", "The app is now in the initial state"); + + expectedQueryParams = {}; + return router.transitionTo('special', {id: 1}); + }, shouldNotHappen).then(function(result) { + deepEqual(router.location.path, '/specials/1', "Correct URL after transitioning"); + + expectedQueryParams = {view: 'details'}; + return router.transitionTo('special', {queryParams: {view: 'details'}}); + }, shouldNotHappen).then(function (result) { + deepEqual(router.location.path, '/specials/1?view=details', "Correct URL after transitioning with route name and query params"); + + expectedQueryParams = {view: 'other'}; + return router.transitionTo({queryParams: {view: 'other'}}); + }, shouldNotHappen).then(function (result) { + deepEqual(router.location.path, '/specials/1?view=other', "Correct URL after transitioning with query params only"); + + expectedQueryParams = {view: 'three'}; + return router.transitionTo("/specials/1?view=three"); + }, shouldNotHappen).then(function (result) { + deepEqual(router.location.path, '/specials/1?view=three', "Correct URL after transitioning with url"); + + start(); + }, shouldNotHappen); + }); + }); + + + asyncTest("Transitioning query params works on a different route", function() { + expect(46); + + var expectedQueryParams, expectedOtherQueryParams; + + Router.map(function() { + this.route("home", { path: "/" }); + this.resource("special", { path: "/specials/:menu_item_id", queryParams: ['view'] }); + this.resource("other", { path: "/others/:menu_item_id", queryParams: ['view', 'lang'] }); + }); + + App.SpecialRoute = Ember.Route.extend({ + beforeModel: function (transition, queryParams) { + deepEqual(queryParams, expectedQueryParams, "The query params are correct in the beforeModel hook"); + }, + + model: function(params, transition, queryParams) { + deepEqual(queryParams, expectedQueryParams, "The query params are correct in the model hook"); + return {id: params.menu_item_id}; + }, + + afterModel: function (resolvedModel, transition, queryParams) { + deepEqual(queryParams, expectedQueryParams, "The query params are correct in the beforeModel hook"); + }, + + setupController: function (controller, context, queryParams) { + deepEqual(queryParams, expectedQueryParams, "The query params are correct in the setupController hook"); + }, + + renderTemplate: function (controller, context, queryParams) { + deepEqual(queryParams, expectedQueryParams, "The query params are correct in the renderTemplates hook"); + }, + + serialize: function (obj) { + return {menu_item_id: obj.id}; + } + }); + + App.OtherRoute = Ember.Route.extend({ + beforeModel: function (transition, queryParams) { + deepEqual(queryParams, expectedOtherQueryParams, "The query params are correct in the beforeModel hook"); + }, + + model: function(params, transition, queryParams) { + deepEqual(queryParams, expectedOtherQueryParams, "The query params are correct in the model hook"); + return {id: params.menu_item_id}; + }, + + afterModel: function (resolvedModel, transition, queryParams) { + deepEqual(queryParams, expectedOtherQueryParams, "The query params are correct in the beforeModel hook"); + }, + + setupController: function (controller, context, queryParams) { + deepEqual(queryParams, expectedOtherQueryParams, "The query params are correct in the setupController hook"); + }, + + renderTemplate: function (controller, context, queryParams) { + deepEqual(queryParams, expectedOtherQueryParams, "The query params are correct in the renderTemplates hook"); + }, + + serialize: function (obj) { + return {menu_item_id: obj.id}; + } + }); + + + + Ember.TEMPLATES.home = Ember.Handlebars.compile( + "

Home

" + ); + + + Ember.TEMPLATES.special = Ember.Handlebars.compile( + "

{{content.id}}

" + ); + + bootApplication(); + + var transition = handleURL('/'); + + Ember.run(function() { + transition.then(function() { + equal(Ember.$('h3', '#qunit-fixture').text(), "Home", "The app is now in the initial state"); + + expectedQueryParams = {}; + + return router.transitionTo('special', {id: 1}); + }, shouldNotHappen).then(function(result) { + deepEqual(router.location.path, '/specials/1', "Correct URL after transitioning"); + + expectedQueryParams = {view: 'details'}; + return router.transitionTo('special', {queryParams: {view: 'details'}}); + }, shouldNotHappen).then(function (result) { + deepEqual(router.location.path, '/specials/1?view=details', "Correct URL after transitioning with route name and query params"); + + expectedOtherQueryParams = {view: 'details'}; + + return router.transitionTo('other', {id: 2}); + }, shouldNotHappen).then(function (result) { + deepEqual(router.location.path, '/others/2?view=details', "Correct URL after transitioning to other route"); + + expectedOtherQueryParams = {view: 'details', lang: 'en'}; + return router.transitionTo({queryParams: {lang: 'en'}}); + }, shouldNotHappen).then(function (result) { + deepEqual(router.location.path, '/others/2?view=details&lang=en', "Correct URL after transitioning to other route"); + + expectedQueryParams = {view: 'details'}; + + return router.transitionTo("special", {id: 2}); + }, shouldNotHappen).then(function (result) { + deepEqual(router.location.path, '/specials/2?view=details', "Correct URL after back to special route"); + + expectedQueryParams = {}; + + return router.transitionTo({queryParams: false}); + }, shouldNotHappen).then(function (result) { + deepEqual(router.location.path, '/specials/2', "queryParams: false clears queryParams"); + + expectedQueryParams = {view: 'details'}; + + return router.transitionTo("special", {id: 2}, {queryParams: {view: 'details'}}); + }, shouldNotHappen).then(function (result) { + deepEqual(router.location.path, '/specials/2?view=details', "Correct URL after back to special route"); + + expectedQueryParams = {}; + + return router.transitionTo("/specials/2"); + }, shouldNotHappen).then(function (result) { + deepEqual(router.location.path, '/specials/2', "url transition clears queryParams"); + + start(); + }, shouldNotHappen); + }); + }); + + + + test("Redirecting to the current target with a different query param aborts the remainder of the routes", function() { + expect(4); + + Router.map(function() { + this.route("home"); + this.resource("foo", function() { + this.resource("bar", { path: "bar/:id" }, function() { + this.route("baz", { queryParams: ['foo']}); + }); + }); + }); + + var model = { id: 2 }; + + var count = 0; + + App.BarRoute = Ember.Route.extend({ + afterModel: function(context) { + if (count++ > 10) { + ok(false, 'infinite loop'); + } else { + this.transitionTo("bar.baz", {queryParams: {foo: 'bar'}}); + } + }, + + serialize: function(params) { + return params; + } + }); + + App.BarBazRoute = Ember.Route.extend({ + setupController: function() { + ok(true, "Should still invoke setupController"); + } + }); + + bootApplication(); + + handleURLAborts("/foo/bar/2/baz"); + + equal(router.container.lookup('controller:application').get('currentPath'), 'foo.bar.baz'); + equal(router.get('location').getURL(), "/foo/bar/2/baz?foo=bar"); + }); + + test("Parent route query params change", function() { + expect(4); + + var editCount = 0, + expectedQueryParams = {}; + + Ember.TEMPLATES.application = compile("{{outlet}}"); + Ember.TEMPLATES.posts = compile("{{outlet}}"); + Ember.TEMPLATES.post = compile("{{outlet}}"); + Ember.TEMPLATES['post/index'] = compile("showing"); + Ember.TEMPLATES['post/edit'] = compile("editing"); + + Router.map(function() { + this.resource("posts", {queryParams: ['sort']}, function() { + this.resource("post", { path: "/:postId" }, function() { + this.route("edit"); + }); + }); + }); + + App.PostsRoute = Ember.Route.extend({ + events: { + sort: function(dir) { + this.transitionTo({queryParams: {sort: dir}}); + } + }, + + setupController: function(controller, context, queryParams) { + deepEqual(queryParams, expectedQueryParams, "Posts route has correct query params"); + } + }); + + App.PostRoute = Ember.Route.extend({ + events: { + editPost: function(context) { + this.transitionTo('post.edit'); + } + } + }); + + App.PostEditRoute = Ember.Route.extend({ + setup: function() { + editCount++; + } + }); + + bootApplication(); + + handleURL("/posts/1"); + + Ember.run(function() { + router.send('editPost'); + }); + + expectedQueryParams = {sort: 'desc'}; + + Ember.run(function() { + router.send('sort', 'desc'); + }); + + equal(editCount, 2, 'set up the edit route twice without failure'); + }); +}