Experimenting With "Query String Zones" In AngularJS
In AngularJS, it's quite easy to integrate routes into an application because routes are mutually exclusive. Meaning, you can't be located at two different routes at the same time. Query string parameters, on the other hand, are a different beast. Query string parameters don't have to be mutually exclusive; you could mutate some subset of parameters while leaving the rest alone. That said, AngularJS doesn't provide any native functionality for building anchor links around such behavior. As such, I wanted to experiment with the idea of creating "query string zones" within an application that would manage some subset of query string values.
Run this demo in my JavaScript Demos project on GitHub.
To explore this idea, I created two custom directives - ngQueryStringZone and ngQueryString - that would help manage a subset of query string parameters. Like the ngAnchor directive, the ngQueryString directive ultimately generates an HREF attribute by merging the provided value with the existing location. The ngQueryString directive can stand on its own; or, it can work with the ngQueryStringZone to "zero-out" certain parameters in a given context.
To use the ngQueryString directive, you just have to provide the subset of parameters that you want to affect. For example:
<a ng-query-string="foo=true">Goto Foo</a>
This will create an HREF value in which the "foo=true" key-value pair is merged into the existing location. And, as the location changes, the generated HREF will also change in order to keep the link relevant.
On its own, the ngQueryString directive is only additive. Meaning, it will only add the given key-value pairs but it won't remove anything from the generated HREF. If you want a "zone" of the application to manage some subset of keys a bit more holistically, you can provided a parent ngQueryStringZone directive that defines the set of keys to manage. For example:
<div ng-query-string-zone="[ 'foo', 'bar', 'baz' ]"> ... </div>
This would work in conjunction with the nested ngQueryString directives to make sure that "foo", "bar", and "baz" are removed from the generated HREF values before the ngQueryString-specific values are merged in. This gives you the ability to "reset" a subset of the query string parameters without having to provide them explicitly.
And, if you want extra fine control, you can actually define a sibling directive that requires the "ngQueryStringZone" controller. Once injected, you can overwrite the default ".merge" method on the zone controller to provide custom wrangling of the values.
It's easier to see this in action if you actually run the demo, since its all about how changes are applied to HREF values. But, here is the code:
<!doctype html>
<html ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Experimenting With "Query String Zones" In AngularJS
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body ng-controller="AppController">
<h1>
Experimenting With "Query String Zones" In AngularJS
</h1>
<h2>
Hard-Coded Routes
</h2>
<p>
<a href="#/foo">Foo</a> |
<a href="#/bar?id=3">Bar</a> |
<a href="#/meep?id=17&confirm=true#anchor">Meep</a>
</p>
<h2>
Query-String Only
</h2>
<p>
This zone manages "A", "B", and "C".
</p>
<!--
The ngQueryStringZone and ngQueryString directives work together to merge query
string values into an HREF attribute as the location of the page changes. Then,
the bnDemoZone can work with the ngQueryStringZone directive to manage a subset
of query string parameters for a given area of the page.
In this case, this "zone" will work with the params A, B, and C, leaving the rest
of the existing query string values in-place.
-->
<div ng-query-string-zone="[ 'A', 'B', 'C' ]" bn-demo-zone-DISABLED>
<p>
<a ng-query-string="A=1">Goto Thing A</a> |
<a ng-query-string="A=2&B=101">Goto Thing B</a> |
<a ng-query-string="A=3&B=201&C=301">Goto Thing C</a>
</p>
</div>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/angularjs/angular-1.4.3.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
angular.module( "Demo", [] );
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I control the root of the demo.
angular.module( "Demo" ).controller(
"AppController",
function AppController( $scope, $location ) {
// When the location changes, let's just log-out the parts to see how
// the are being affected.
$scope.$on(
"$locationChangeSuccess",
function logLocationChange() {
console.log(
"Location changed: [%s] [%s] [%s]",
$location.path(),
JSON.stringify( $location.search() ),
$location.hash()
);
}
);
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I work in conjunction with the ngQueryStringZone directive to define the merge
// algorithm used to integrate the zone-oriented query string params.
angular.module( "Demo" ).directive(
"bnDemoZone",
function bnDemoZoneDirective() {
// Return the directive configuration object.
// --
// NOTE: We require the ngQueryStringZone directive controller.
return({
link: link,
require: "ngQueryStringZone",
restrict: "A"
});
// I bind the JavaScript events to the view-model.
function link( scope, element, attributes, zoneController ) {
// The whole point of this directive is manage the way the zone-based
// query string params are integrated into the existing URL. Just
// like the normal $location.search() method, we can set params to
// [null] in order to remove them URL.
zoneController.merge = function( search, params, originalMerge ) {
// To demonstrate how this differs from the default zone behavior,
// we'll set the values to "default" rather than "null.
search.A = "default";
search.B = "default";
search.C = "default";
// Hand-off to the original merge algorithm.
originalMerge( search, params );
};
}
}
);
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// I define a "zone" within the application that will help manage the integration
// of query string parameters into the existing URL for the generation of HREFs.
// If the directive is provided with an array of keys, it will "nullify" those
// keys before it performs the integration.
angular.module( "Demo" ).directive(
"ngQueryStringZone",
function ngQueryStringZoneDirective() {
// Return the directive configuration object.
return({
controller: ZoneController,
link: link,
require: "ngQueryStringZone",
restrict: "A"
});
// I provide an API that can be overridden by a sibling directive that
// wants to supply a custom merge() method.
function ZoneController() {
return({
merge: null
});
}
// I bind the JavaScript events to the view-model.
function link( scope, element, attributes, zoneController ) {
// If we were not provided with an expression to watch, there's no
// way for us to provide a merge algorithm.
if ( ! attributes.ngQueryStringZone ) {
return;
}
// Watch the collection of keys. If it changes, we need to redefine
// the merge algorithm to use.
scope.$watchCollection(
attributes.ngQueryStringZone,
function defineCustomMerge( keys ) {
zoneController.merge = function( search, params, originalMerge ) {
// Set all the given keys to null before we pass control
// back to the original merge algorithm. This will prevent
// not-explicitly-provided params from showing up in the
// generated URL.
for ( var i = 0, length = keys.length ; i < length ; i++ ) {
search[ keys[ i ] ] = null;
}
originalMerge( search, params );
};
}
);
}
}
);
// I observe the given query string subset and generate a full HREF attribute
// that merges the given subset into the existing location.
angular.module( "Demo" ).directive(
"ngQueryString",
function ngQueryStringDirective( $location ) {
// Return the directive configuration object.
return({
link: link,
require: "^?ngQueryStringZone",
restrict: "A"
});
// I bind the JavaScript events to the view-model.
function link( scope, element, attributes, zoneController ) {
// Whenever the attribute or the location changes, we need to
// recalculate the HREF for this element.
attributes.$observe( "ngQueryString", updateHref );
scope.$on( "$locationChangeStart", updateHref );
// I update the HREF attribute, integrating the current query string
// substring into the existing location.
function updateHref() {
var search = angular.copy( $location.search() );
var params = parseQueryString( attributes.ngQueryString );
// If we have a zoneController, use the merge-override.
if ( zoneController && zoneController.merge ) {
zoneController.merge( search, params, merge );
} else {
merge( search, params );
}
// Update the element HREF to use the integrated URL.
attributes.$set(
"href",
buildUrl( $location.path(), search, $location.hash() )
);
}
}
// ---
// STATIC METHODS.
// ---
// I build a valid URL using the given components.
function buildUrl( path, search, hash ) {
var url = ( "#" + path );
var newQueryString = [];
for ( var key in search ) {
// In order to given the "zone" an opportunity to delete query
// params, we're going to skip over any value that is explicitly
// set to [null].
if ( search[ key ] === null ) {
continue;
}
newQueryString.push( key + "=" + search[ key ] );
}
if ( newQueryString.length ) {
url += ( "?" + newQueryString.join( "&" ) );
}
if ( hash ) {
url += ( "#" + hash );
}
return( url );
}
// I merge the given query params into the given search params.
function merge( search, params ) {
for ( var key in params ) {
search[ key ] = params[ key ];
}
}
// I parse the given query string into a set of key-value pairs.
function parseQueryString( queryString ) {
var parsed = {};
var pairs = queryString.split( "&" );
for ( var i = 0, length = pairs.length ; i < length ; i++ ) {
var parts = pairs[ i ].split( "=" );
if ( parts.length === 1 ) {
parts[ 1 ] = true;
}
parsed[ parts[ 0 ] ] = parts[ 1 ];
}
return( parsed );
}
}
);
</script>
</body>
</html>
Notice that I have the zone set to manage "A", "B", and "C". This way, if I go from "Bar" to "C" and then to "A", I get the following output:
Again, it's hard to see what is actually going on without running the demo. But, essentially, the ngQueryString directives are generating HREF links that only affect the appropriate portions of the current location.
NOTE: The whole point of this is to generate actual HREF links that work "naturally". If you don't care about being able to right-click and copy an HREF, then don't bother jumping through these hoops - just build some navigation method in your controller that updates the $location.search() collection.
Most of the time, your application view is going to be driven by the route. But, sometimes, it might be nice to have a "featurette" driven by the state of the query string. In this way, you could have one feature existing over the main view while still keeping a somewhat natural back-button experience. Since there is no native way to do this, custom directives like ngQueryString can help generate HREF values that only affect the query-string portion of the URL.
Want to use code from this post? Check out the license.
Reader Comments