Skip to content
This repository has been archived by the owner on Mar 1, 2024. It is now read-only.

Building a SPA Website with Alchemy Angular

Jerry Thompson edited this page Sep 23, 2016 · 11 revisions

This article will show you how to use the alchemy-angular gem with AngularJS to build a one page web site.

AngularJS

Angular doesn't need an introduction anymore. Google's frontend JS framework
is a huge success and ubiquitous.

Alchemy CMS

Alchemy is a content management framework written in Ruby On Rails and is used by hundreds of websites around the globe. It gives you, the developer, the power
to build your customer exactly the cms they need.

Never tried it? Try it!

Let's Go!

First install Alchemy CMS and build your app:

$ gem install alchemy_cms --pre  
$ alchemy new angular-demo  

We are using bower-rails to handle frontend js dependencies. Add it to your Gemfile:

# Gemfile
...
gem 'alchemy_cms', github: 'magiclabs/alchemy_cms', branch: 'master'
gem 'alchemy-angular', github: 'magiclabs/alchemy-angular', branch: 'master'
gem 'bower-rails', group: 'development'
...
  • Note: Since we want to use the new /api namespaced JSON API urls
    from Alchemy we need to specify the master branch in the Gemfile

Also we add the alchemy-angular gem, that provides directives, controllers
and routes for a seamless Alchemy integration.

Don't forget to install the dependencies with bundler:

$ bundle install

Now let's add Angular and some useful modules into your Bowerfile:

# Bowerfile
asset 'angular'
asset 'angular-route'
asset 'angular-animate'
asset 'angular-sanitize'
asset 'underscore'

And install them with:

$ bin/rake bower:install

  • Notice how bower-rails installs all dependencies into
    vendor/assets/bower_components. It is highly recommended to
    add these to your git repository. Rails will then kindly compile
    them into the asset pipeline for you. Great!

Define some elements and page layouts

We need to define some elements and page layouts in Alchemy in order to
manage some content. ( If you are not familiar with page layouts and elements, I
highly recommend to read the Guideline ).

Let's keep it short and simple. An article element and one basic page layout
is enough for the purpose of this demo.

In your config/alchemy/elements.yml file add:

# config/alchemy/elements.yml
- name: article
  contents:
  - name: headline
    type: EssenceText
  - name: color
    type: EssenceSelect
    default: black
  - name: logo
    type: EssencePicture
  - name: text
    type: EssenceRichtext

And in your config/alchemy/page_layouts.yml file:

# config/alchemy/page_layouts.yml
- name: standard
  elements: [article]

Like I said, short and simple.

Views

Use Alchemy's view generator for elements and page layouts.

$ bin/rails g alchemy:page_layouts --skip
$ bin/rails g alchemy:elements --skip

But, since we are not going to render the views via Rails, we can safely remove
the generated element views and add some Angular templates instead.

$ rm app/views/alchemy/elements/_article_view.html.erb

As we use the angular-rails-templates gem, we put the Angular templates into app/assets/javascripts/angular/templates inside an alchemy/elements folder.

The template file itself is named like the element. (You see, we follow
common Alchemy naming patterns here. Naming over configuration)

So, the full path to our template is:

app/assets/javascripts/angular/templates/alchemy/elements/article.html

and here is how it looks like:

<article class="{{ingredient('color').value}}">
  <h1 ng-if="ingredient_present('headline')">{{ingredient('headline').value}}</h1>
  <img ng-if="ingredient_present('logo')" ng-src="{{ingredient('logo').value}}" class="logo">
  <div ng-bind-html="ingredient('text').value" class="text"></div>
</article>

You see we already use a function called ingredient in the views.
Also we use a useful helper function to check to see if the Angular
should render the dom element or not.

Where does it all come from? It is provided by the
alchemyElement directive from the alchemy-angular gem.

# app/assets/javascripts/alchemy-angular/directives/alchemy_element.js.coffee
App.directive 'alchemyElement', ['$sce', ($sce) ->
  {
    restrict: 'E'
    $scope: {
      element: "="
    }
    replace: true
    link: (scope, element, attrs) ->

      # Every element has its very own template.
      # The elements are placed at your angular template path
      # with 'alchemy/elements' prefix
      scope.elementTemplate = "alchemy/elements/#{scope.element.name}.html"

      # The ingredients of current element
      scope.ingredients = scope.element.ingredients

      # Returns all ingredients with given name
      scope.ingredients_with_name = (name) ->
        _.where(scope.ingredients, {name: name})

      # Returns the ingredient value of given name
      scope.ingredient = (name) ->
        _.findWhere(scope.ingredients, {name: name})

      # Checks if the ingredient with given name is not null or empty
      scope.ingredient_present = (name) ->
        ingredient = scope.ingredient(name)
        return false unless ingredient
        ingredient.value && ingredient.value != ''

      return
    template: '<div ng-include="elementTemplate"></div>'
  }
]
  • Note: You should only need to pass in $sce for Strict Contextual Escaping
    if you are using AngularJS < v.1.2

So, wait. What happens here? Angular does not really support using dynamic templates from current scope. So we need to use this ng-include trick to make dynamic template support work.

What we are basically doing here is to get the current element's name and include the template into the template wrapper.

Ok, and how do we use this directive and how does it get the data?

Controller, Service and Routes

First of all we need a controller, that uses a service to load the data from Alchemy's JSON API.

The Pages Controller

The controller simply takes the params provided by the router and fetches the data via the AlchemyPage service from the Alchemy JSON API.

The controller also sets some values on the $scope and $rootScope. If a Google Analytics tracker is present it also tracks the page hit.

App.controller('AlchemyPagesController',
  ['$rootScope', '$scope', '$routeParams', 'AlchemyPage',
   ($rootScope, $scope, $routeParams, AlchemyPage) ->

    # Track a google analytics page hit
    trackGAPageHit = (url, title) ->
      window.ga 'send',
        'hitType': 'pageview',
        'page': url,
        'title': title

    # Load the page via service
    #
    request = AlchemyPage.load($routeParams)

    # Sets these values on success:
    #
    # $rootScope.title
    # $rootScope.metaKeywords
    # $rootScope.metaDescription
    # $scope.elements
    # $scope.cells
    # $scope.pageLayout
    #
    request.success (response) ->
      # if google analytics object is present we track the page hit
      if window.ga?
        trackGAPageHit("/#{$routeParams.page}", response.title)
      $rootScope.title = response.title
      $rootScope.metaKeywords = response.meta_keywords
      $rootScope.metaDescription = response.meta_description
      $scope.elements = response.elements
      $scope.cells = response.cells
      $scope.pageLayout = response.page_layout
      return

    return
  ]
)

The Page service

App.service 'AlchemyPage', ['$http', ($http) ->

  url_for = (params) ->
    if params.id
      # To make this work in Alchemy preview frame we need to load the page from id
      "/api/admin/pages/#{params.id}.json"
    else if params.page
      # The url from page param
      "/api/pages/#{params.page}.json"
    else
      throw new Error("No params to load page from found! You need to provide an object with either `id` or `page` attribute.")

  # Loads the page from Alchemy's JSON API via given params and returns a promise
  #
  @load = (params) ->
    $http.get url_for(params)

  return
]

And the router:

App.config ['$routeProvider', '$locationProvider', ($routeProvider, $locationProvider) ->
  $locationProvider.html5Mode(true)

  $routeProvider
    # Route for displaying this page in Alchemy page preview
    .when '/admin/pages/:id',
      controller: 'AlchemyPagesController'
      templateUrl: 'alchemy/page.html'
    # Route for displaying page
    .when '/:page*',
      controller: 'AlchemyPagesController'
      templateUrl: 'alchemy/page.html'
    # Route not found -> 404
    .otherwise
      templateUrl: '/404.html'

  return
]

Now we need to wire everything together.

Connecting the dots

In your application.html.erb template you only need to use the ng-view directive on a container div and the everything else is handled for you.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <base href="/">
    <meta name="fragment" content="!">
    <%= stylesheet_link_tag :application, media: 'all' %>
    <%= javascript_include_tag :application %>
  </head>
  <body>
    <div ng-view class="container"></div>
    <footer>
      <%= render_navigation %>
    </footer>
  </body>
</html>

Pretty straight forward. We can even use the navigation renderer from Alchemy, because Angular catches the requests and loads the content we need from our API.

That's it! A nice one page webpage managed through a CMS!