Skip to content
David Cook edited this page Jun 24, 2024 · 38 revisions

OpenFoodNetwork uses Angular.js for the public-facing interface. Angular is a complex beast, so we've put together this article showing you how it all fits together.

Our application poses some uncommon issues and so contains some unusual design choices - deviating from the "Angular way" in specific cases.

Note that we are in the process of saying "Bye Bye Angular", but still need to support the existing Angular implementation.

Things you should read

Big picture

The pretty new layout is called 'Darkswarm'

  • All the relevant Coffeescript is inside app/assets/javascripts/darkswarm
  • The relevant layout is 'layouts/darkswarm'
  • Anything outside Darkswarm should (and must) be redundant
  • The name is a joke between Rohan and Will. Don't ask.

We use Sass, Haml, Coffeescript, Guard and LiveReload

All of these are fairly self-explanatory. We've got a nice workflow with Guard and Livereload that'll auto-refresh your browser; run:

guard

Each page is a separate controller/view

For simplicity we've elected to keep each page separate, rendered server-side. We are not using Angular routes or views.

Angular templates are automatically compiled and injected

Anything inside app/assets/javascripts/templates will automatically be compiled, and injected by name into Angular's templateCache. Haml is supported. Templates can be accessed via templateUrl, e.g.

templateUrl: 'foo.html'

Data is injected on page load

We're using helpers (in InjectionHelper) to inject JSON data into pages onload. This is faster than an Ajax request, especially when data is required immediately.

:javascript
  angular.module('Darkswarm').value("#{name.to_s}", #{json})

The 'value' can be injected into a service. This will throw an exception if you inject a value that hasn't been defined: so make sure your data is in place before injecting services that require it.

Jasmine/Karma is being used for unit tests

Services, controllers and some aspects of directives should be tested. This is clunkier than using Rspec, but gets easier with practice!

For integration testing, we've elected to use Capybara with Rspec rather than Protractor. Many discussions have been had about this: in the end, it's the only option we've found viable.

Everything is done in Coffeescript

To make full use of Coffeescript you'll need to learn the idioms. coffeescriptcookbook.com and coffeescript.org are your weapons of choice.

It's well-worth reading both these pages several times. Coffeescript has terse and elegant syntax; brain-bending but awesome. With practice you'll be able to avoid most of the ugly Javascript idioms.

Technical exposition

Angular Foundation

We're making heavy use of Angular Foundation (AF). This provides us with various directives and tools to use Foundation components.

Currently we're using a fork with some minor modifications. These changes are slated for removal; we'll be reverting to vanilla AF ASAP once the badly-written tabs are replaced with something more idiomatic

Since AF doesn't always precisely meet our needs, we've written our own directives that make use of and extend it. For example see the PriceBreakdown directive:

Darkswarm.directive "priceBreakdown", ($tooltip)->
  tooltip = $tooltip 'priceBreakdown', 'priceBreakdown', 'click' 
  tooltip.scope = 
    variant: "="
  tooltip
Darkswarm.directive 'priceBreakdownPopup', ->
  restrict: 'EA'
  replace: true
  templateUrl: 'price_breakdown.html'
  scope: true

This directive uses AF's $tooltip service (which generates a directive object), then patches it (replacing the scope definition with an isolate scope). This allows us to use most of the logic inside $tooltip (show, hide, events, onscreen placement, etc) while using our own directive for the tooltip itself.

The priceBreakdownPopup directive (which controls our tooltip) is named according to a convention established in the $tooltip service, allowing us to use our own template and potentially our own behaviour.

Active Model Serializers (AMS)

We use AMS to render JSON, although some older injection is still being done with Rabl templates. AMS supports key-cased caching, which we use with memcache to improve JSON rendering performance.

Serializers are scoped to the API namespace; serializers are automatically associated with models by name, causing changes to vanilla to_json serialization.

class EnterpriseSerializer < ActiveModel::Serializer
end
Enterprise.new.to_json # automatically uses the serializer

vs

module Api
 class EnterpriseSerializer < ActiveModel::Serializer
  end
end
Enterprise.new.to_json # The serializer must be invoked explicitly, otherwise serializing behaves as expected.

https://github.com/openfoodfoundation/openfoodnetwork/wiki/Angular-and-OFN/_edit#

Injecting data into services

Many of our Angular services require data to be injected on page load. It's common to see exceptions indicating required data isn't available. For example:

Darkswarm.factory 'Enterprises', (enterprises, CurrentHub, Taxons, Dereferencer)->
  new class Enterprises

This service will sometimes throw:

Error: [$injector:unpr] Unknown provider: enterprisesProvider <- enterprises <- Enterprises <- Hubs

Indicating that the "enterprises" value has not been set and cannot be injected into the Enterprises service. This can be resolved in the view:

= inject_enterprises

Dereferencing

We're making heavy use of a technique called dereferencing. This is possible because Javascript supports pointers, allowing us to build circularly-referenced data structures.

foo.bar.foo == foo # true
bar.foo.bar == bar # true

When dereferencing, we load our data in which associations between models are represented with IDs:

enterprise:
  associated_enterprises:
    id: 1
    id: 2

Once this data is loaded we perform the dereferencing step, replacing the IDs with pointers to corresponding objects. This generates a web of pointers between objects client-side. Duplication is avoided, so there's only a single object of a given type/ID.

For convenience we have service called Dereferencer that will automatically perform dereferencing when supplied with an array of objects to dereference, and an ID-keyed hash of objects to point to.

Darkswarm.factory 'Dereferencer', ->
  new class Dereferencer
    dereference: (array, data)->
      if array
        for object, i in array
          array[i] = data[object.id]

A practical example:

enterprise_1 =
  id: 1
  associated_enterprises:
    id: 2
enterprise_2 = 
  id: 2
  associated_enterprise:
    id: 1

after dereferencing, we get:

enterprise_1.associated_enterprises[0] == enterprise_2 # true
enterprise_2.associated_enterprises[0] == enterprise_1 # true

The Cart

The cart is an elegant but counter-intuitive bit of code and requires some explanation. The various components are:

Darkswarm.factory 'CurrentOrder', (currentOrder) ->
  new class CurrentOrder
    order: currentOrder

This represents the current order, pulled from the server (e.g. current_order). This is global, injected on page load, always-available and scoped to a single user.

Darkswarm.factory 'Cart', (CurrentOrder, Variants, $timeout, $http)->
  # Handles syncing of current cart/order state to server
  new class Cart
    order: CurrentOrder.order
    # <snip>

This service wraps the CurrentOrder and adds some additional logic. It is responsible for the creation of LineItems and sending updates to the Cart to the server.

LineItems are where the magic happens. Each LineItem looks like this:

line_item:
  variant: <pointer>
  quantity: <integer>
  max_quantity: <integer>

Pointers to variants are via dereferencing (see above). The initial set of LineItems in the Cart service is injected by the server.

Darkswarm.factory 'Products', ($resource, Enterprises, Dereferencer, Taxons, Cart, Variants) ->
  new class Products
  # <snip>

The Products service represents all products available for purchase in the currently selected OrderCycle and Distributor. This logic is handled by the server; the service simply points to a JSON endpoint at /products.

The magic is in these two methods:

registerVariants: ->
  for product in @products
    if product.variants
      product.variants = (Variants.register variant for variant in product.variants)
    product.master = Variants.register product.master if product.master

Registers every variant with the Variants service; either creating or returning an existing variant.

registerVariantsWithCart: ->
  for product in @products
    if product.variants
      for variant in product.variants
        Cart.register_variant variant
    Cart.register_variant product.master if product.master

Registers every variant with the Cart service. This will create a new LineItem for any variant without one.

register_variant: (variant)=>
  exists = @line_items.some (li)-> li.variant == variant
  @create_line_item(variant) unless exists 
    
create_line_item: (variant)->
  variant.line_item =
    variant: variant
    quantity: 0
    max_quantity: null
  @line_items.push variant.line_item

I18n - using translation features

We load an object called I18n that contains all translations for certain keys. Ideally, all language text is contained in that object and no language text is in code/template files. Instead, you call a translate function with a key to translate.

In Angular controllers, you can use the global function t to translate:

# Example: app/assets/javascripts/darkswarm/controllers/authentication/login_controller.js.coffee
# t('logging_in') will return "Hold on a moment, we're logging you in"
Loading.message = t 'logging_in'

In Angular views, you should use the filter t to translate:

-# Example:  app/assets/javascripts/templates/registration/contact.html.haml
-# The enterprise name is given as parameter to the translate function which produces:
-# "Who is responsible for managing %{enterprise}?"
%h5{ "ng-bind" => "'who_is_managing_enterprise' | t:{enterprise: enterprise.name}" }

Gotchas

Karma configuration

There's a manifest file at spec/javascripts/application_spec.js. Relevant files must also be included here. Global stubs (e.g. disabling GMaps) should be put here as well.

Always render in Angular

It can be tempting to attempt to hybridize Rails and Angular, e.g. rendering with ERB, or injecting data using ng-init. DON'T.

When building an Angular page, Rails should be responsible for rendering basic HTML, injecting appropriate initialization data using the appropriate helpers, and providing any necessary Ajax endpoints. Angular must be responsible for all view logic, rendering, data-manipulation etc.

Minor things

  • Some functions must explicitly return null, e.g. module ($provide)
Clone this wiki locally