Implementing a Grouping Function in CoffeeScript

November 24th 2014 DocPad CoffeeScript Node.js

Encouraged by Scott Hanselman's Get Involved video, I started experimenting with DocPad. For someone, who's been mostly focused on .NET framework and Microsoft technologies, this turned out to be quite a step outside the comfort zone. Not in a bad way, though. Learning Node.js, CoffeeScript, and Embedded CoffeeScript (ECO) templates is an interesting challenge and a great way to broaden my horizons.

Recently I wanted to perform a simple task of grouping a list of pages by the month, they were published in. In .NET I would use LINQ and be done with it in a matter of minutes. Doing this in my new technology stack took a bit more time. My first working solution was not something, I'd want to show to anyone. However, after some refactoring and cleaning up, I ended up with code, I decided to even blog about (mostly to serve as a reference while I continue to learn more).

My starting point in DocPad was the result of the following call:

posts = @getCollection("blogposts").toJSON()

Here's a sample of it:

var posts =
[
  {
    "date": newDate("2014-11-10"),
    "url": "ScopeNinjectBindingsToIndividualTests.aspx",
    "title": "Scope Ninject Bindings to Individual Tests"
  },
  {
    "date": newDate("2014-11-03"),
    "url": "CustomizingComparisonsInFluentAssertionsWithOptions.aspx",
    "title": "Customizing Comparisons in FluentAssertions with Options"
  },
  {
    "date": newDate("2014-10-27"),
    "url": "LightMessageBusAStandaloneLightweightEventAggregator.aspx",
    "title": "LightMessageBus: A Standalone Lightweight Event Aggregator"
  }
];

It's a collection of page meta data, already sorted descendingly by date.

Surprisingly (at least to me) there's already a built-in reduce function available on JavaScript arrays, you just need to call it correctly:

postsByMonth = posts.reduce((previous, current, index, context) ->
  month = moment(current.date).format("MMMM YYYY")
  if previous[month]
    previous[month].push(current)
  else
    previous[month] = [ current ]
  previous
{})

I started out with an empty object (passed in as context). For each month I added a property to it. Each property contains an array of posts for the month. The function returns the modified object so that it can be passed back in for the next call. I'm using moment.js library to convert the date into a string representing the month.

With just a little more effort this can be converted into a reusable grouping function:

arrayGroupBy = (array, aggregate) ->
  array.reduce((previous, current, index, context) ->
    group = aggregate(current)
    if previous[group]
      previous[group].push(current)
    else
      previous[group] = [ current ]
    previous
  {})

The aggregate function can be passed into it as a parameter:

postsByMonth = arrayGroupBy(posts, (post) ->
  moment(current.date).format("MMMM YYYY"))

Since I was going to use the date formatting elsewhere, I extracted it into a function as well, and changed the call accordingly:

dateToMonthAndYear = (date) -> moment(date).format("MMMM YYYY")
postsByMonth = arrayGroupBy(posts, (post) -> dateToMonthAndYear(current.date))

Now I was ready to use my code in the page template:

<ul>
<% posts = @getCollection("blogposts").toJSON() %>
<% aggregate = (post) => @dateToMonthAndYear(post.date) %>
<% postsByMonth = @arrayGroupBy(posts, aggregate) %>
<% for month in Object.keys(postsByMonth): %>
  <li><%= month %></li>
  <ul>
  <% for post in postsByMonth[month]: %>
    <li>
      <a href="<%= post.url %>">
        <%= post.title %>
      </a>
    </li>
  <% end %>
  </ul>
<% end %>
</ul>

Notice, how I first iterated through properties of postsByMonth by calling Object.keys(), and then through the posts by accessing the object properties as items in an associative array.

For those familiar with DocPad, I should mention that I have declared dateToMonthAndYear and arrayGroupBy in docpad.coffee configuration file, that's why I'm accessing them using the @ shortcut syntax for this in CoffeeScript. Here's the relevant part of my configuration file:

moment = require('moment')

docpadConfig = {
  templateData:
    dateToMonthAndYear: (date) -> moment(date).format("MMMM YYYY")
    arrayGroupBy: (array, aggregate) ->
      array.reduce((previous, current, index, context) ->
        group = aggregate(current)
        if previous[group]
          previous[group].push(current)
        else
          previous[group] = [ current ]
        previous
      {})
}

It's also worth mentioning, that I've used the fat arrow when declaring the aggregate. This way this got bound to its value in the template, making dateToMonthAndYear function available.

Yes, I know there is a LINQ library available for JavaScript, which I could use instead, but I think it's a bit of an overkill in this case. And the end result wouldn't be all that different either, although a bit easier to understand for .NET developers:

moment = require('moment')
enumerable = require('linq')

docpadConfig = {
  templateData:
    dateToMonthAndYear: (date) -> moment(date).format("MMMM YYYY")
    arrayGroupBy: (array, aggregate) -> enumerable.
      from(array).
      groupBy((post) -> aggregate(post)).
      select((group) ->
        {
          key: group.key()
          values: group.toArray()
        }).
      toArray()
}

Since arrayGroupBy now returns an array instead of an object, the looping in the template would be implemented slightly differently as well:

<ul>
<% posts = @getCollection("blogposts").toJSON() %>
<% aggregate = (post) => @dateToMonthAndYear(post.date) %>
<% postsByMonth = @arrayGroupBy(posts, aggregate) %>
<% for month in postsByMonth: %>
  <li><%= month.key %></li>
  <ul>
  <% for post in month.values: %>
    <li>
      <a href="<%= post.url %>">
        <%= post.title %>
      </a>
    </li>
  <% end %>
  </ul>
<% end %>
</ul>

I don't know about you, but I decided not to use linq.js for now. I might change my mind if I'll need more complex array manipulation in my project.

Get notified when a new blog post is published (usually every Friday):

Copyright
Creative Commons License