Creating New Records with Ember's Embedded Record Mixin

This post will provide one approach to creating and persisting new records with complex associations in Ember and Rails using the Rails Active Model Serializer, Ember Active Model Adapter and Active Model Serializer and Ember's Embedded Record Mixin.

Background

Let's start with some background on the app. We have three models: artists, songs and albums. Artists have many songs and songs have many artists. Songs belong to an album and albums have many songs. Albums have many artists through songs.

We'll assume we've already built out our Rails migrations, models and controllers so we'll start by taking a close look at our Rails serializers. We'll assume that we have CORS set up on our Rails app to accept requests from our Ember app running on localhost:4200. We'll assume our Ember routes and templates are built out but we'll take a close look at our adapter, models and serializers. Lastly, we'll build the form for a new album with its associated new songs and artists, along with the necessary component and controller actions for persisting those new records.

Alright, let's get started!

Rails Active Model Serializer

First, make sure you have gem 'active_model_serializers' in your Gemfile and bundle install. Then, we'll build out our serializers starting with the serializer for the Artist model.

# app/serializers/artist_serializer

class ArtistSerializer < ActiveModel::Serializer
  embed :ids, include: true
  attributes :id, :name
  has_many :albums
  has_many :songs
end

The first line, embed :ids, include: true will embed the association ids and include objects in the root of the served JSON. This, paired with the has_many macros, will ensure that associated song and alum records are side loaded when our Rails API serves the record of a given artist.

So, provided our Artists Controller is properly set up with a #show action that looks like this:

# app/controllers/api/v1/artists_controller.rb
module Api
  module V1
    class ArtistsController < ApplicationController
      ...

      def show
        artist = Artist.find(params[:id])
        render json: artist
      end
    end
  end
end

The served JSON should look like this:

# localhost:3000/api/v1/artists/1

{
  artist: {
    id: 1,
    name: "Justin Bieber",
    album_ids: [
      1,
      60,
      105,
      6
    ],
    song_ids: [
      1,
      3,
      4,
      6,
      9,
      14,
      17,
    ]
  },
  albums: [
    {
      id: 1,
      name: "Purpose (Deluxe)",
      image_url:  "https://i.scdn.co/image/d30f10db04a3303623d70d7d930da769f5590019",
      artist_ids: [
        14,
        27,
        1,
        57,
        23
      ],
      song_ids: [
        1,
        3,
        4,
        6,
        14,
        17,
        19,
        22,
        23,
        28,
        32,
        33,
        35,
        39,
        55,
        58,
        68,
        77
      ]
    },
    {
      id: 60,
      name: "Sorry (Latino Remix)",
      image_url:    "https://i.scdn.co/image/3ad7e80aec5def243cb7598b00037f0882cef076",
      artist_ids: [
        1,
        53
      ],
      song_ids: [
        92
      ]
    },
    {
      id: 105,
      name: "Home To Mama",
      image_url: "https://i.scdn.co/image/c262b304e9e8e8ae0b79c89479b9faeb08321227",
      artist_ids: [
        1,
        134
      ],
      song_ids: [
        155
      ]
    },
    {
      id: 6,
      name: "Skrillex and Diplo present Jack Ü",
      image_url: "https://i.scdn.co/image/292c18a8d58184c6943033e10aa1deb98efc07de",
      artist_ids: [
        6,
        8,
        1,
        7
      ],
      song_ids: [
        9
      ]
    }
  ],

songs: [
    {
      id: 1,
      name: "Sorry",
      artist_ids: [
        1
      ],
      album_id: 1
    },
    {
      id: 3,
      name: "Love Yourself",
      artist_ids: [
        1
      ],
      album_id: 1
    },
    {
      id: 4,
      name: "What Do You Mean?",
      artist_ids: [
        1
      ],
      album_id: 1
    },
  ]
}

Notice that the artist record that we requested comes with the collection of song ids and album ids, as well as with the serialized song and album records.

Let's take a look at our Song Serializer now:

class SongSerializer < ActiveModel::Serializer
  attributes :id, :name, :artist_ids, :album_id
end

This is one is a bit more straight forward. This time, we're not side loading, or loading the associated records, just the ids of the associated artists and album.

Here is the JSON that gets served when we visit localhost:3000/api/v1/songs

{
song: {
  id: 1,
  name: "Sorry",
  artist_ids: [
    1
  ],
  album_id: 1
 }
}

Lastly, let's check out our Album Serializer:

class AlbumSerializer < ActiveModel::Serializer
  attributes :id, :name, :image_url, :artist_ids, :song_ids
end

And here's the JSON that visiting localhost:3000/api/v1/albums/1 serves:

{
  album: {
    id: 1,
    name: "Purpose (Deluxe)",
    image_url: "https://i.scdn.co/image/d30f10db04a3303623d70d7d930da769f5590019",
    artist_ids: [
      14,
      27,
       1,
      57,
      23
    ],
    song_ids: [
      1,
      3,
      4,
      6,
      14,
      17,
    ]
  }
}

Okay, now that we understand what data we're serving to Ember, let's take a look at our Ember adapter, models and serializers.

Using Ember Data with Rails Active Model Serializer

The biggest pain point for me in building out this feature was my failure to understand the Rails Active Model Serializer needs Ember Active Model Adapter, needs Ember Active Model Serializer. So, I just want to emphasize that requirement for anyone out there who may be similarly confused.

Now that we have that out of the way, let's take a quick look at our adapter, models and serializers.

Ember Active Model Adapter
import ActiveModelAdapter from 'active-model-adapter';

export default ActiveModelAdapter.extend({
  namespace: 'api/v1',
  host: 'http://localhost:3000',
  authorizer: 'authorizer:devise'
});

We're using the Active Model Adapter so that our Ember app can easily consume and correctly deserialize the JSON data that is served to it from our Rails API.

Ember Models

We need to build out our Ember models to have the same relationships as our Rails models.

// app/models/album.js

import DS from 'ember-data';

export default DS.Model.extend({
  name: DS.attr('string'),
  imageUrl: DS.attr('string'),
  songs: DS.hasMany('song', {asnyc: true}),
  artists: DS.hasMany('artist', { async: true })
});
// app/models/song.js

import DS from 'ember-data';

export default DS.Model.extend({
  name: DS.attr('string'),
  artists: DS.hasMany('artist', { async: true }),
  album: DS.belongsTo('album', {async: true})
});
// app/models/artist.js

import DS from 'ember-data';

export default DS.Model.extend({
  name: DS.attr('string'),
  songs: DS.hasMany('song'),
  albums: DS.hasMany('album')
});

Note that both the Song and Album model use the async: true option, because Rails is not side loading the associated records. The Artist model, however, doesn't need the async: true option because artist records do side load associated songs and albums.

Ember Active Model Serializer and the Embedded Record Mixin

In order to be enable our users to fill out one form that creates a new album along with its associated songs and artists, all in one go, we need to tell Ember to serialize and send this associated data as one package back to Rails.

To do so, we'll use Ember's Active Model Serializer together with the Embedded Record Mixin.

We'll build two serializers: an album serializer, in order to allow for an album record to be posted to Rails with its associated songs, and a song serializer, in order for those songs to contain the information regarding their associated artists. In other words, any album record that gets posted to our Rails API should embed the associated songs and artists within it.

Let's build those serializers.

Album Serializer

import DS from 'ember-data';

import { ActiveModelSerializer } from 'active-model-adapter';

export default ActiveModelSerializer.extend(DS.EmbeddedRecordsMixin, {
  attrs: {
    songs: {serialize: 'records'}
  }
});

Here we are using the Embedded Record Mixin to give Ember explicit instructions on how to serialize album data to send to our API. We set the attrs property to songs: {serialize: 'records'}. This tells Ember that album records should be serialized with their associated songs and that those associated song details should include the entire song record.

Note: You can instead serialize just the ids, or you can set serialize to false. We don't want to only serialize ids, however, because we are posting data regarding new song and artist records. These records therefore do not yet have ids. They will get their ids from the Rails back-end when the records are inserted into the database.

It's important to note that we are not using the deserialize option here. Since we've already added the async: true option to our Album model when we defined its relationship to songs and artists, we can't use the deserialize option in our serializer.

Song Serializer

import DS from 'ember-data';

import { ActiveModelSerializer } from 'active-model-adapter';

export default ActiveModelSerializer.extend(DS.EmbeddedRecordsMixin, {
  attrs: {
    artists: {serialize: 'records'},
  }
});

In our Song serializer, we once again use the Embedded Record Mixin to embed artist records within the payload that Ember will send to our API, when we post the record of the song associated to this artist.

With our serializers set up correctly, posting an album record to our API should send a payload that looks like this:

{
 "album"=>
  {"name"=>"My New Album",
   "image_url"=>nil,
   "songs"=>
    [{"name"=>"Greatest Hit no. 1",
      "artists"=>[
         {"name"=>"Sophie"},    
         {"name"=>"Josh"}]},
     {"name"=>"Greatest Hit no. 2", 
      "artists"=>[
         {"name"=>"Sophie"}]}
    ]
 }
}

Okay, now that our app is equipped to correctly serialize embedded records and send them back to our API, let's build out the form for a new album.

Creating and Posting Embedded Records From Ember

We'll need to build a form for a new album, including fields for adding new songs and their associated artists dynamically. We'll need to build actions to add song and artist fields to the page and an action to handle submitting, or posting, that form's data to our API.

New Album Form and Component

We'll generate and render the album-form component on the app/templates/new.hbs template. Ultimately, we'll want our albums/new controller to save(), or post the new album record to our API. So we'll pass in that controller's save action to our component as a closure action:

{{album-form album=model triggerSave=(action "save")}}

To begin with, our form needs a field for the album name:

<form>
  {{input value=album.name placeholder="album name" class="form-control"}}
  
  <button type="submit" class="btn btn-default" {{action "submit"}}>create album</button>

</form>

And our album-form object needs to respond to that submit action we've assigned to the "create album" button by triggering the closure action we just established:

import Ember from 'ember';

export default Ember.Component.extend({

  actions: {
    submit(){
      this.attrs.triggerSave();
    }
  }
});

Dynamically Adding Song Fields

Now, let's add some form fields for the album's songs:

<form>
  {{input value=album.name placeholder="album name" class="form-control"}}
  <br>
  
  {{#each album.songs as |song|}}
    <div class="form-group">

      {{input value=song.name class="form-control" placeholder="song name"}}

    </div>
  {{/each}}
  
  <button type="submit" class="btn btn-default" {{action "submit"}}>create album</button>

</form>

At this point, however, our album object doesn't have any songs associated with it. So the code we just added to our form won't generate any form fields for us.

Let's add an "add song" button that a user can click to generate form fields for new songs.

To do this, we'll create an action in our albums/new controller that adds an empty song object to the model, i.e. our new, empty album object:

import Ember from 'ember';

export default Ember.Controller.extend({
  actions: {
    addSong(){
      let album = this.get('model');
      let song = this.store.createRecord('song');
      album.get('songs').addObject(song);
    },
  }
});

Now, we'll pass this action into our album-form component as a closure action:

{{album-form album=model 
triggerSave=(action "save")
triggerAddSong=(action "addSong")
}}

Lastly, we'll add an "add song" button to our component template and associate that button with an action that will fire our closure action:

{{!-- app/templates/components/new-album.hbs --}}

<form>
  {{input value=album.name placeholder="album name" class="form-control"}}

 <button type="button" {{action "increaseArtists" song}}>add artist</button>
  
  {{#each album.songs as |song|}}
    <div class="form-group">

      {{input value=song.name class="form-control" placeholder="song name"}}

    </div>
  {{/each}}
  
  <button type="submit" class="btn btn-default" {{action "submit"}}>create album</button>

</form>
// app/components/new-album.js

import Ember from 'ember';

export default Ember.Component.extend({

  actions: {
    increaseSongs(){
      this.attrs.triggerAddSong();
    },

    submit(){
      this.attrs.triggerSave();
    }
  }
});

Great! Now, a user's click of the "add song" button will dynamically add song form fields to the page, and each of the those songs will already be associated to the album.

Now we need to follow the same pattern for the song's artists.

Dynamically Adding Artist Fields

Let's start by adding to our form, an iteration over a given song's artists:

<form>
  {{input value=album.name placeholder="album name" class="form-control"}}
  <br>
  
  <button type="button" {{action "increaseSongs"}}>Add a new song</button>
    
  {{#each album.songs as |song|}}
    {{input value=song.name class="form-control" placeholder="song name"}}
      
    {{#each song.artists as |artist|}}
        {{input value=artist.name class="form-control" placeholder="artist name"}}
    {{/each}}

  {{/each}}

  <button type="submit" class="btn btn-default" {{action "submit"}}>create album</button>

</form>

However, once again, this will not generate any artist form fields for us, because the songs that get dynamically created as empty objects and added to the page don't have any artists.

We need to add an action to our albums/new controller that will create empty artist objects and add them to the appropriate song.

How can such an action add the new artist object to the correct song? We'll have to pass the song object in as an argument from the component template, when we trigger the action. Let's define our addArtist action in the album/new controller to take in an argument of a song:

import Ember from 'ember';

export default Ember.Controller.extend({
  actions: {
   ...

    addArtist(song){
      let artist = this.store.createRecord('artist');
      song.get('artists').addObject(artist);
    }

  ...
  }
});

Then, we'll pass this action into our component as a closure action:

{{album-form album=model 
  triggerAddSong=(action "addSong")
  triggerAddArtist=(action "addArtist") 
  triggerSave=(action "save")
}}

Lastly, we'll add an "add an artist" button to our component template, and create an action in that component that triggers the addArtist controller action, passing in an argument of the song.

<form>
  {{input value=album.name placeholder="album name" class="form-control"}}
  <br>
  
  <button type="button" {{action "increaseSongs"}}>Add a new song</button>
    
  {{#each album.songs as |song index|}}
      <div class="form-group">
        {{input value=song.name class="form-control" placeholder="song name"}}

        <button type="button" name="button" {{action "increaseArtists" song}} class="btn-xs btn-primary">add artist</button>

        {{#each song.artists as |artist|}}
          {{input value=artist.name class="form-control" placeholder="artist name"}}
        {{/each}}

      </div>
    {{/each}}
  <button type="submit" class="btn btn-default" {{action "submit"}}>create album</button>

</form>

Notice that we add the action to the "add artist" button *with an argument of the song that we are currently iterating over: {{action "increaseArtists" song}}.

This allows us to do the following in our increaseArtists component action:

import Ember from 'ember';

export default Ember.Component.extend({

  actions: {
    ...

    increaseArtists(song){
      this.attrs.triggerAddArtist(song);
    }

   ...
  }
});

Now that we can dynamically add song and artist fields to our form, we're ready to define our save action in the album/new controller.

Saving the Album Record


export default Ember.Controller.extend({
  actions: {
    addSong(){
      let album = this.get('model');
      let song = this.store.createRecord('song');
      album.get('songs').addObject(song);
    }, 

    addArtist(song){
      let artist = this.store.createRecord('artist');
      song.get('artists').addObject(artist);
    },

    save(){
      let album = this.get('model');
      album.save().then((newAlbum)=>{
        newAlbum.get('songs').filterBy('id', null).invoke('deleteRecord');
        this.transitionToRoute('albums.album', newAlbum.id);
      });
    }
  }
});

In the save action, we retrieve the model, submit the record (along with its associated records, thanks to the Embedded Record Mixin) and then, using a callback function that will run once we get a response back from the API, transition to the route of the newly created album.

Note: This callback function has one line that I'm not happy about:

newAlbum.get('songs').filterBy('id', null).invoke('deleteRecord');

Seems like Ember is unable to reconcile embedded song records returned from the API with the ones already in memory. It therefore creates duplicate records, resulting in an album object that has a collection of songs, half of which are the newly created songs, with id, and half of which are the in-memory songs with ids of null. I wasn't able to find a fix other than this workaround but if anyone has thoughts/better solutions/can tell me what I'm missing, please comment.

Anyway...we're almost done! We just need to update the #create action of our Rails Albums Controller to create new albums with all of that great embedded song and artist data:

# app/controllers/api/v1/albums_controller.rb

def create
  album = Album.create(album_params)
  params[:album][:songs].each do |song_hash|
    song = Song.create(name: song_hash[:name])
    album.songs << song
    
    song_hash[:artists].each do |artist|
      song.artists << Artist.create(name:  artist[:name])
    end
    
    song.save
  end

  album.save
  render json: album
end

Not the most elegant method, but you get the idea.

And that's it! Our form should be up and running, something like this:

subscribe and never miss a post!

Blog Logo

Sophie DeBenedetto

comments powered by Disqus
comments powered by Disqus