-
-
Notifications
You must be signed in to change notification settings - Fork 725
Angular and OFN
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.
- Understanding Scopes
- Coffeescript Cookbook
- Coffeescript.org
- A Practical Guide to Angular.js Directives
- A Practice Guide to Angular.js Directives Part 2 (all the juicy bits)
- Angular Directives for Foundation
- Declaring Angular Services with Coffeescript
- 80/20 Guide to Using Angular.JS filters
- 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.
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
For simplicity we've elected to keep each page separate, rendered server-side. We are not using Angular routes or views.
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'
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.
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.
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.
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.
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#
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
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 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
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}" }
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.
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.
- Some functions must explicitly return null, e.g. module ($provide)
Development environment setup
- Pipeline development process
- Bug severity
- Feature template (epic)
- Internationalisation (i18n)
- Dependency updates
Development
- Developer Guidelines
- The process of review, test, merge and deploy
- Making a great commit
- Making a great pull request
- Code Conventions
- Database migrations
- Testing and Rspec Tips
- Automated Testing Gotchas
- Rubocop
- Angular and OFN
- Feature toggles
- Stimulus and Turbo
Testing
- Testing process
- OFN Testing Documentation (Handbooks)
- Continuous Integration
- Parallelized test suite with knapsack
- Karma
Releasing
Specific features
Data and APIs
Instance-specific configuration
External services
Design