Calculating Derived Datasets Using Objects As Indexes In AngularJS 1.2.22
At InVision, when I retrieve data from the server, the payload that's returned contains all the necessary information for the view. But, it's not always in the necessary view-model format. As such, I have to generate some degree of client-side data based on the AJAX response. Lately, I've been using the JavaScript Object
to create derived datasets that I then index based on a look-up key that I'll have handy in my Angular templates. This pattern has been working really well for me; so, I wanted to put together a quick demo in AngularJS 1.2.22.
NOTE: AngularJS is basically end-of-life (giving way to the modern Angular framework). However, our legacy platform - that my team and I maintain - still runs on AngularJS 1.x. That said, this technique will work just as nicely in modern Angular with TypeScript.
Run this demo in my JavaScript Demos project on GitHub.
View this code in my JavaScript Demos project on GitHub.
One of the most common data translations that I have to perform is converting UTC milliseconds into human-friendly date-strings. In our database, as is the de facto standard, all date/time values are stored in the UTC timeszone. When generating AJAX responses, we then serialize those date/time values as UTC milliseconds so that the client-side code can easily render the date/time strings in the user's local timezone.
In order make this a pain-free operation, I'll create an Object
with no prototype:
var dateTranslations = Object.create( null );
Then, I'll populate this dateTranslations
Object
using the UTC milliseconds at the look-up key:
dateTranslations[ utcMilliseconds ] = formatDate( utcMilliseconds );
This leaves me with a mapping of date/time values that is indexed by the UTC milliseconds. And now, when I'm rendering my AngularJS template, to output a human-friendly date-string, all I have to do is reference the dateTranslations
index:
Created at {{ dateTranslations[ utcMilliseconds ] }}.
More and more, I've been using this type of approach, where I have a "raw dataset" that I then either wrap in another, mutable dataset or that I use to then derive additional dataset as with the dateTranslations
above. What I'm really loving about this approach is that keeps the underlying dataset in-tact, which is nice for caching; but, I can - at any moment - just regenerate all the derived data.
To see this in action, I've put together a small task-list demo wherein I have two Object
indexes: one for date/time translations and one to keep track of whether or not a task is overdue. I'm also wrapping the tasks in another Array
that I can sort:
<!doctype html>
<html lang="en" ng-app="Demo">
<head>
<meta charset="utf-8" />
<title>
Calculating Derived Datasets Using Objects As Indexes In AngularJS 1.2.22
</title>
<link rel="stylesheet" type="text/css" href="./demo.css">
</head>
<body ng-controller="appController">
<h1>
Calculating Derived Datasets Using Objects As Indexes In AngularJS 1.2.22
</h1>
<h2>
Tasks
</h2>
<form ng-submit="addTask()" class="form">
<input
type="text"
ng-model="form.task"
placeholder="Add new task..."
autofocus
size="50"
class="form__input"
/>
<button type="submit" class="form__button">
Add task
</button>
</form>
<ul>
<li
ng-repeat="task in sortedTasks track by task.id"
class="task">
<div class="task__description">
{{ task.description }}
</div>
<!--
In the following meta-data area, notice that we are using our derived
data indexes to output the human-friendly date and any indication that
the task is overdue.
--
NOTE: One nice thing about this approach is that, if there was a bug in
our code, the look-up values will just coalesce to undefined, which will
result in no output. ie, nothing will explode if something goes wrong.
-->
<div class="task__meta">
Created {{ dateTranslations[ task.createdAt ] }} —
Due {{ dateTranslations[ task.dueAt ] }}
<span ng-if="overdueFlags[ task.id ]" class="task__overdue">
Overdue
</span>
</div>
</li>
</ul>
<!-- ---------------------------------------------------------------------------- -->
<!-- ---------------------------------------------------------------------------- -->
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/jquery/jquery-2.1.0.min.js"></script>
<script type="text/javascript" src="../../vendor/angularjs/angular-1.2.22.min.js"></script>
<script type="text/javascript">
// Create an application module for our demo.
var app = angular.module( "Demo", [] );
</script>
<script type="text/javascript">
app.controller( "appController", AppController );
function AppController( $filter, $location, $scope ) {
$scope.tasks = buildDemoTasks();
$scope.form = {
task: ""
};
// Our derived datasets.
$scope.sortedTasks = null;
$scope.dateTranslations = Object.create( null );
$scope.overdueFlags = Object.create( null );
// Our human-readable date-strings and overdue-flags are derived from the
// current tasks collection. These can be re-calculated at any time if the
// underlying dataset changes.
setSortedTasks();
setDateTranslations();
setOverdueFlags();
// Expose the public methods.
$scope.addTask = addTask;
// ---
// PUBLIC METHODS.
// ---
// I process the new-task form.
function addTask() {
if ( ! $scope.form.task ) {
return;
}
// For the sake of the demo, we'll just put in some hard-coded values.
var createdAt = Date.now();
var dueAt = ( createdAt + ( 1000 * 60 * 60 * 24 ) );
$scope.tasks.push({
id: createdAt,
description: $scope.form.task,
createdAt: createdAt,
dueAt: dueAt
});
$scope.form.task = "";
// Since we are using derived datasets for parts of our view-model,
// it means that we can simply re-run our calculations anytime the
// underlying view-model changes. This is brute-force, but dead-simple.
setSortedTasks();
setDateTranslations();
setOverdueFlags();
}
// ---
// PRIVATE METHODS.
// ---
// I build the array of tasks for the demo.
function buildDemoTasks() {
var id = 0;
// Note that in the demo data, which would be coming from a remote API,
// the date/time objects are returned as UTC milliseconds. We will use
// derived objects to make this data more consumable.
return([
{
id: ++id,
description: "Do this thing over here.",
createdAt: 1617249600000,
dueAt: 1618286400000
},
{
id: ++id,
description: "Do that thing over there.",
createdAt: 1618891200000,
dueAt: 1619323200000
},
{
id: ++id,
description: "Do another thing or else you're in trouble.",
createdAt: 1618977600000,
dueAt: 1620360000000
}
]);
}
// I format the given UTC milliseconds as a human-readable string.
function formatDate( tickcount ) {
return( $filter( "date" )( tickcount, "MMMM d, yyyy" ) );
}
// I populate the date-translations using the current tasks view-model.
function setDateTranslations() {
$scope.tasks.forEach(
function iterator( task ) {
$scope.dateTranslations[ task.createdAt ] = formatDate( task.createdAt );
$scope.dateTranslations[ task.dueAt ] = formatDate( task.dueAt );
}
);
}
// I populate the overdue-flags using the current tasks view-model.
function setOverdueFlags() {
var now = Date.now();
$scope.tasks.forEach(
function iterator( task ) {
$scope.overdueFlags[ task.id ] = ( task.dueAt <= now );
}
);
}
// I populate the sorted tasks using the current tasks view-model.
function setSortedTasks() {
// Sort by created date DESCENDING (ie, newest tasks first).
$scope.sortedTasks = $scope.tasks.slice().sort(
function comparator( a, b ) {
return( b.createdAt - a.createdAt );
}
);
}
}
</script>
</body>
</html>
By using derived datasets, all we have to do is re-calculate the derive datasets whenever the underlying "raw dataset" is changed. That could be during component initialization when the data is first made available; or, it could be later-on when the raw data is updates (such as by adding a new task). In either case, we just run our "setters" to build-up our derivations:
setSortedTasks()
setDateTranslations()
setOverdueFlags()
Each of these methods is coupled to the existence of the underlying view-model. But, I have not found this coupling to be problematic. And, if we run this AngularJS code in the browser and add some tasks, we get the following output:
As you can see, all of the human-friendly date/time strings and "overdue" flags are output easily using the Object
indexes. And, after we add our new tasks, by re-running the data derivation methods, all the new task data is translated as well. This may require brute-force, but it's very little effort.
Personally, I'm not a huge fan of ReactJS. But, I do appreciate how much React errs on the side of re-calculating data all the time. In AngularJS, I don't need to do it all the time; but, I can easily re-calculate derived data when the underlying data changes. And, in this vein, I've found that using the Object
as an index of the derived data makes rendering views very simple.
Want to use code from this post? Check out the license.
Reader Comments
Love this! Very clean. Very simple. I've struggled with derived date strings...playing with formatting them in the query, in CF, or in JS. As a result, We've tried all and have a mishmash of approaches, which means I can never assume any one approach was taken when I revisit old code that the team has written. As always, thanks for sharing.
I couldn't think of a case where this would happen, so maybe it's a non-factor, but I generally tend to use functions instead of object mappings because then i can easily trap for a non-existent key and set a default.
For example...
@Chris,
re: Functions for trapping non-existent values, I'm down to clown. I've become a huge fan of the Elvis operator (null coalescing) lately for exactly what you are saying. For example, your
return
statement could be written as:return( endpoints[ env ] ?: endpoints.prod )
There's no real difference - it's doing the same things, so it just comes down to preference and readability. You could easily argue that your syntax is more readable; so, to each their own.
But, even in the JavaScript layer, we have to deal with optional values. So, going back to the
dateTranslations
stuff, we sometimes have optional dates and those will either get sent back as the empty string or as0
(zero). In either case, I have to handle that. So, I'll have something like this:Here, I'm creating
0 -> ""
translations. And then, in the HTML, I just have to mirror that kind of logic:The lovely thing about JavaScript is that it treats empty strings and zeros and null and undefined all as Falsy values. Which means that they all work really well with
ng-if
directives.