By Emmanuelle Delescolle

With

Part 2

Who am I?

  • An average developper
  • Who loves working with Django
  • A woman who codes
  • Someone prone to burnouts

Getting your computer ready for the workshop

Before you start, you should have completed part 1 of this workshop

Let's write a few tests

First in Django

import random

from factory import fuzzy, SubFactory, Faker, lazy_attribute
from factory.django import DjangoModelFactory
from ..models import Category, Product


class CategoryFactory(DjangoModelFactory):

    class Meta:
        model = Category

    name = fuzzy.FuzzyText(
        prefix='Category ',
        length=3
    )


LOREM_CATEGORIES = [
    'food',
    'animals',
    'technics',
]

back/catalog/tests/factories.py

class ProductFactory(DjangoModelFactory):

    class Meta:
        model = Product

    name = Faker('word')
    category = SubFactory(CategoryFactory)
    description = Faker('text')
    image = lazy_attribute(
        lambda o: 'http://lorempixel.com/200/300/{}'.format(
            random.choice(LOREM_CATEGORIES)
    ))
    price = fuzzy.FuzzyDecimal(5)

First in Django

from .factories import ProductFactory, CategoryFactory
from my_webshop.tests.base import BaseAPITestCase
from rest_framework.test import APITestCase
from ..models import Product, Category
from ..serializers import ProductSerializer, CategorySerializer


class CategoryAPITest(BaseAPITestCase, APITestCase):

  api_base_name = 'category'
  model = Category
  model_factory_class = CategoryFactory
  serializer_class = CategorySerializer

  api_is_read_only = True


class ProductAPITest(BaseAPITestCase, APITestCase):

  api_base_name = 'product'
  model = Product
  model_factory_class = ProductFactory
  serializer_class = ProductSerializer

  api_is_read_only = True

back/catalog/tests/test_api.py

First in Dango

$ ./manage.py test
Creating test database for alias 'default'...
...............
----------------------------------------------------------------------
Ran 15 tests in 0.201s

OK
Destroying test database for alias 'default'...

In your shell (in back)

🎉                             🎉

Then in Ember...

Two very similar failures

Ember unit tests are run in deep isolation

Let's fix that!

...
moduleForModel('category', 'Unit | Model | category', {
  // Specify the other units that are required for this test.
  needs: ['model:product']     
});
...

front/tests/unit/models/category-test.js

...
moduleForModel('product', 'Unit | Model | product', {
  // Specify the other units that are required for this test.
  needs: ['model:category']    
});
...

front/tests/unit/models/product-test.js

Factory data

import FactoryGuy from 'ember-data-factory-guy';   
                                                   
FactoryGuy.define('category', {                    
  sequences: {
    categoryName: (num) => {                       
      return `Category ${num}`;                    
    }
  },
  default: {                                       
    name: FactoryGuy.generate('categoryName')      
  } 
}); 

front/tests/factories/category.js

$ ember g factory category

In your shell (in front)

Factory data (with faker)

import FactoryGuy from 'ember-data-factory-guy';   
import { faker } from 'ember-cli-mirage';          

FactoryGuy.define('product', {                     
  sequences: {                                     
    productName: (num) => {                        
      return `Product ${num}`;                     
    }
  },
  default: {                                       
    name: FactoryGuy.generate('productName'),      
    category: FactoryGuy.belongsTo('category'),    
    description: faker.lorem.paragraph,            
    image: faker.image.imageUrl,                   
    price: faker.commerce.price                    
  }
}); 

front/tests/factories/product.js

$ ember g factory product

In your shell (in front)

Ember integration test

import { moduleForComponent, test } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';       
import { manualSetup, make } from 'ember-data-factory-guy'; 

moduleForComponent('searchable-products', 'Integration | Component | searchable products', {
  integration: true,                                
  
  beforeEach: function() {                          
    manualSetup(this.container);                    
  } 
}); 
  
test('it renders', function(assert) {               
  const model = [];
    
  for(let i=0; i<2; i++) {                          
    const c = make('category');                     
    const ct = Math.floor((Math.random * 2) + 2);   
    for(let j=0; j<ct; j++) {                       
      model.push(make('product', { category: c }));   
    }
  } 
    
  this.set('model', model);                         
  this.render(hbs`{{searchable-products model=model}}`);

  assert.equal(this.$().text().trim(), '');         
});

front/tests/integration/components/searchable-products/component-test.js

🎉 Congratulations! 🎉

Let's actually tests "searchable-"

import { contains } from '../../../helpers/sl/synchronous';

...

test('it renders', function(assert) {
  let model = [];

...
     
  assert.equal(this.$().text().trim(), '');
      
  const found = make('product',{name: 'Find me'});
  const hidden = make('product', {name: 'Hide me'});
    
  model = [found, hidden];
    
  this.set('model', model);    
  this.render(hbs`{{searchable-products model=model searchTerm='fiNd'}}`);
  
  assert.ok(contains(this.$().text(), 'Find me'));
  assert.notOk(contains(this.$().text(), 'Hide me'));
});

front/tests/integration/components/searchable-products/component-test.js

Ember services

Cart service

import Ember from 'ember';

export default Ember.Service.extend({
  items: Ember.A(),

  append(product, qty) {
    const match = this.get('items').findBy('product', product);
    if (match) {
      Ember.set(match, 'qty', match.qty + qty);
    } else {
      this.get('items').pushObject({ product, qty });
    } 
  },

  count: Ember.computed('items', 'items.length', 'items.@each.qty', function() {
    return this.get('items').reduce(function(sum, item) {
      return sum + items.qty;
    }, 0);
  })
});
$ ember g service cart

In your shell (in front)

front/app/services/cart.js

Actions

They bubble up from route to route!
💭
💭
💭
💭
💭

Calling a simple action

which in turn uses our brand new service

...
export default Ember.Route.extend(ApplicationRouteMixin, {
  authenticator: 'authenticator:django',
  cart: Ember.inject.service(),
    
  model() {
    return sections;
  },

  actions: {
    addToCart(product) {     
      this.get('cart').append(product, 1);
    },
...

front/app/application/route.js

...
    {{model.description}}    
    <br/>
    <button class="btn btn-primary" {{action 'addToCart' model}}>Buy</button>
</div>

front/app/product/template.hbs

Calling a simple action

which in turn uses our brand new service

Shopping cart component

import Ember from 'ember';

export default Ember.Component.extend({
  cart: Ember.inject.service()
});

front/app/components/shopping-cart/template.hbs

$ ember g component shopping-cart --pod

In your shell (in front)

<i class="fa fa-shopping-basket fa-2x"></i> 
{{#if cart.items.length}}
  <span class="badge">{{cart.count}}</span>
{{/if}}

front/app/components/shopping-cart/component.js

& {
  margin: $grid-gutter-width / 2;
  display: flex;
  flex-direction: row;
  > span.badge {
    align-self: flex-start;
    background-color: $brand-warning;
    margin-left: - $grid-gutter-width / 4;
  }
}

front/app/components/shopping-cart/styles.scss

Shopping cart component

{{#if session.isAuthenticated}}
  <span class="user-info">Welcome {{session.currentUser.username}}</span>
  {{shopping-cart}}
  <button class="btn btn-danger" {{action 'invalidate' on='click'}}>
    <i class="fa fa-sign-out"></i>  Sign out
  </button>
{{else}}
  {{shopping-cart}} 
  <div class="dropdown login-dropdown">
...

front/app/components/user-info/template.hbs

...
  > div {
    float: left;
  }
}

front/app/components/user-info/styles.scss

Something's broken...

Something's broken...

Our component doesn't implement "block usage", let's not test for it

...
  // REMOVE those lines 
  // // Template block usage:
  // this.render(hbs`  
  //   {{#shopping-cart}}
  //     template block text
  //   {{/shopping-cart}}
  // `);

  // assert.equal(this.$().text().trim(), 'template block text');
});

front/tests/integration/components/shopping-cart/component-test.js

The basket route

<h2>Your basket</h2>           
        
{{# if model.count}}            
  <div class="table-responsive">  
    <table class="table table-striped">
      <thead>
        <tr><th colspan="2">Item</th><th>Qty</th>
          <th>Ttl price</th></tr>
      </thead>                 
      <tbody>
        {{#each model.items as |item|}}  
          <tr>
            <td class="tn-col">
              <img src="{{item.product.image}}">
            </td>
            <td>{{item.product.name}}</td>  
            <td class="qty-col">{{item.qty}}</td>
            <td class="price-col"></td>     
          </tr>
        {{/each}}
      </tbody>
      <tfoot>
        <tr>                   
          <th colspan="3">Total</th>      
          <th class="price-col">$</th>    
        </tr>
      </tfoot>
    </table>
  </div>
{{else}}
  <div class="alert alert-warning">
    <i class="fa fa-warning"></i>
     Your basket is empty
  </div>
{{/if}}

front/app/basket/template.hbs

$ ember g route basket --pod

In your shell (in front)

The basket route

...
@import "site";
@import "../basket/styles";
@import "pod-styles";

front/app/styles/app.scss

{{#link-to 'basket'}}<i class="fa fa-shopping-basket fa-2x"></i>{{/link-to}}
...

front/app/components/shopping-cart/template.hbs

front/app/components/shopping-cart/styles.scss

& {
  a {
    color: $text-color;
  }
...
.table-responsive {
  width: 90vw;
  table {
    width: 100%;
  }
  .qty-col, .price-col {
    text-align: right;
  }
  .tn-col > img {
    max-width: 200px;
    max-height: 150px;
  }
}

front/app/basket/styles.scss

front/app/basket/route.js

import Ember from 'ember';

export default Ember.Route.extend({
  cart: Ember.inject.service(),

  model() {
    return this.get('cart');
  }
});

Simple math:
in the template

<td class="price-col">
  {{number-format
    (mult item.product.price item.qty)
    decimals=2
  }}
</td>

front/app/basket/template.hbs

$ ember install ember-math-helpers
$ ember install ember-string-helpers

In your shell (in front)

Not so simple math:
in the code

<th class="price-col">$ {{number-format model.total decimals=2}}</th>

front/app/basket/template.hbs

...
  }),

  total: Ember.computed('count', function() {
    return this.get('items').reduce(function(sum, item) {
      return sum + item.qty * item.product.get('price');
    }, 0);
  })
});

front/app/services/cart.js

Let's clean this basket

...
  actions: {
    removeFromCart(product) {
      this.get('cart').remove(product);
    },
    addToCart(product) {
...

front/app/application/route.js

...
  }),

  remove(product) {
    this.set('items', this.get('items').rejectBy('product', product));
  }
});

front/app/services/cart.js

Let's clean this basket

...
<thead>
  <tr><th colspan="2">Item</th><th>Qty</th><th>Ttl price</th><th></th></tr>
</thead>
  
...

    <td>
      <a {{action 'removeFromCart' item.product}}>
        <i class="text-danger fa fa-remove"></i>
      </a>
    </td>
  </tr>
{{/each}}

...

    <th class="price-col" colspan="2">
      $ {{number-format model.total decimals=2}}
    </th>
  </tr>
</tfoot>
...

front/app/basket/template.hbs

🎉 Congratulations! 🎉

you now have a fully functional basket!

Checking out

Let's start with the Django side

...
INSTALLED_APPS = (
    ...
    'checkout',
)
...

back/settings.py

from django.db import models                                                                                                        
                                                                                                                                    
from catalog.models import Product                                                                                                  
                                                                                                                                    
                                                                                                                                    
class Order(models.Model):                                                                                                          
                                                                                                                                    
    name = models.CharField(max_length=300)                                                                                         
    phone = models.CharField(max_length=20)                                                                                         
    created = models.DateTimeField(auto_now_add=True)                                                                               
                                                                                                                                    
                                                                                                                                    
class OrderLine(models.Model):                                                                                                      
                                                                                                                                    
    order = models.ForeignKey(Order, related_name='lines')                                                                          
    product = models.ForeignKey(Product)                                                                                            
    qty = models.PositiveIntegerField()

back/checkout/models.py

$ ./manage.py startapp checkout

In your shell (in back)

In your shell (in back)

$ ./manage.py makemigrations
$ ./manage.py migrate

Checkout API - Serializers

from rest_framework import serializers                                                                                              
from .models import Order, OrderLine                                                                                                
                                                                                                                                    
                                                                                                                                    
class OrderLineSerializer(serializers.ModelSerializer):                                                                             
                                                                                                                                    
    class Meta:                                                                                                                     
        model = OrderLine                                                                                                           
        fields = ('id', 'product', 'qty', )                                                                                                                           
                                                                                                                                    
                                                                                                                                    
class OrderSerializer(serializers.ModelSerializer): 

    lines = OrderLineSerializer(many=True)                                                                      
                                                                                                                                    
    class Meta:                                                                                                                     
        model = Order                                                                                                               
        fields = ('id', 'name', 'phone', 'lines', )                                                                                            
                                                                                                     
    def create(self, validated_data):                                                                
        lines = validated_data.pop('lines')                                                          
        instance = Order.objects.create(**validated_data)                                        
        for order_line in lines:                                                                     
            instance.lines.create(**order_line)                                                      
                                                                                                     
        return instance

back/checkout/serializers.py

Checkout API - ViewSet

from rest_framework import viewsets, mixins                                                          
from rest_framework.permissions import AllowAny                                                                                     
                                                                                                                                    
from .models import Order                                                                                                           
from .serializers import OrderSerializer                                                                                            
                                                                                                                                    
                                                                                                                                    
class OrderViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet):                                                               
                                                                                                                                    
    serializer_class = OrderSerializer                                                                                              
    queryset = Order.objects.all()                                                                                                  
    permission_classes = [AllowAny, ]

back/checkout/views.py

...
from checkout.views import OrderViewSet

...

router.register(r'orders', OrderViewSet)

urlpatterns = [
...

back/my_webshop/api_urls.py

Checking out

$ ember g model order
$ ember g model orderline

In your shell (in front)

On the Ember side

import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { hasMany } from 'ember-data/relationships';
    
export default Model.extend({  
  name: attr('string'),
  phone: attr('string'),       
  lines: hasMany('orderline')  
});
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { belongsTo } from 'ember-data/relationships';
    
export default Model.extend({  
  product: belongsTo('product', {
    async: true,
  }),   
  qty: attr('number')
}); 

front/app/models/orderline.js

front/app/models/order.js

Checking out

$ ember g serializer order

In your shell (in front)

A custom serializer in Ember as well

import ApplicationSerializer from '../application/serializer';
import EmbeddedRecordsMixin from 'ember-data/serializers/embedded-records-mixin';   
    
export default ApplicationSerializer.extend(EmbeddedRecordsMixin, {
  attrs: {
    lines: { embedded: 'always' }   
  }     
});   

front/app/serializers/order.js

Checking out

$ ember g route order --pod
$ ember install ember-cli-flash

In your shell (in front)

import Ember from 'ember';     
    
export default Ember.Route.extend({
  cart: Ember.inject.service(),
  flashMessages: Ember.inject.service(),
  model() { return this.get('cart'); },
    
  actions: { saveOrder(name, phone) {
    const record = this.get('store').createRecord('order', { name, phone });
    const lines = record.get('lines');
    this.get('cart.items').forEach((line) => {  
      lines.pushObject(      
        this.get('store').createRecord('orderline', {
          product: line.product, qty: line.qty })
      );
    });
    record.save().then(() => {                
      this.get('flashMessages').success('Thank you for your order');
    }).catch(() => {
      this.get('flashMessages').danger('something went wrong!');
    });
  }}
});

front/app/order/route.js

Checking out

<h2>Checkout</h2>              
{{#if model.count}}            
  <form class="form-horizontal">  
    <h3>Personal information</h3>   
    <div class="form-group">   
      <label for="name" class="col-sm-3 control-label">Name</label>
      <div class="col-sm-9">
        <input type="text" class="form-control" id="name"            
          onChange={{action (mut name) value='target.value'}}>
    </div></div>
    <div class="form-group">   
      <label for="phone" class="col-sm-3 control-label">Phone</label>
      <div class="col-sm-9">   
        <input type="phone" class="form-control" id="phone"
          onChange={{action (mut phone) value='target.value'}}>
    </div></div>
    <div class="form-group">
      <div class="pull-right">
        <button class="btn btn-primary" {{action 'saveOrder' name phone}}>Order</button>
    </div></div>
  </form>
{{else}}
  <div class="alert alert-warning">
    <i class="fa fa-warning"></i> Your basket is empty
  </div>
{{/if}}

front/app/order/template.hbs

Checking out

...
<main class="container">     
  {{#each flashMessages.queue as |flash|}}
    {{flash-message flash=flash messageStyle='boostrap'}}
  {{/each}}
  {{ember-load-remover}}     
  {{liquid-outlet}}          
</main>
...

front/app/application/template.hbs

Displaying flash messages

And a link to the order route

...
    </table>
  </div>
  <div class="pull-right">        
    <a class="btn btn-primary" href="{{href-to 'order'}}">Confirm</a>
  </div>
{{else}}
...

front/app/basket/template.hbs

🎉 Congratulations 🎉

Your customers may now order!

Bonus material

Let's clean things up

...
  }),
   
  clear() {
    this.set('items', Ember.A());   
  } 
});

front/app/services/cart.js

...
      record.save().then(() => {
        this.transitionTo('index');     
        this.get('cart').clear();       
        this.get('flashMessages')
          .success('Thank you for your order');
      }).catch( 
...

front/app/order/route.js

Making things pretty (again)

...
  this.transition(
    this.fromRoute('basket'),
    this.toRoute('order'),   
    this.use('toLeft'),
    this.reverse('toRight')
  );  
  this.transition(           
    this.toRoute('basket'),  
    this.use('toDown')
  );
  this.transition(
    this.fromRoute('order'),
    this.use('toUp')
  );
  this.transition(           
    this.use('fade', { duration: 150  }),
    this.debug()
  );
} 

front/app/transitions.js

Making things pretty (again)

...
    <div class="form-group">
      <div class="pull-left">  
        <a class="btn btn-default" href="{{href-to 'basket'}}">Cancel</a>
      </div>
      <div class="pull-right"> 
        <button class="btn btn-primary" {{action 'saveOrder' name phone}}>Order</button>
      </div>
    </div>
  </form>
{{else}}
...

front/app/order/template.hbs

Getting rid of the flashing empty basket message

...
          this.transitionTo('index');     
          Ember.run.later(()=> {          
            this.get('cart').clear();       
          }, 300);
          this.get('flashMessages').success('Thank you for your order');
...

front/app/order/route.js

Orders admin in Django

...
class Order(models.Model):

...
    @property                                                                                                                       
    def total(self):                                                                                                                
        return sum([line.product.price * line.qty for line in self.lines.all()])                     
                                                                                                                                    
    @property                                                                                                                       
    def title(self):                                                                                                           
        return 'Order #{}'.format(self.id)                                                                                          
                                                                                                                                    
    def __str__(self):                                                                                                              
        return '{} (${})'.format(self.title, self.total)


class OrderLine(models.Model):
...
    def __str__(self):                                                                                                              
        return '{} X {}'.format(self.qty, self.product.name)

back/checkout/models.py

First let's improve our models

Orders admin in Django

from django.contrib import admin                                                                                                    
                                                                                                                                    
from .models import Order, OrderLine                                                                                                
                                                                                                                                    
                                                                                                                                    
class OrderLineInline(admin.TabularInline):                                                                                         
                                                                                                                                    
    model = OrderLine                                                                                                               
    extra = 0
    fields = ('qty', 'product', )                                                                                                                      
                                                                                                                                    
                                                                                                                                    
@admin.register(Order)                                                                                                              
class OrderAdmin(admin.ModelAdmin):                                                                                                 
                                                                                                                                    
    list_display = ['title', 'total', ]                                                                                        
    inlines = [OrderLineInline, ]                                                                                                   
                                                                                                                                    
    def get_queryset(self, request):                                                                                                
        return super(OrderAdmin, self).get_queryset(request) \                                                                      
            .prefetch_related('lines', 'lines__product')

back/checkout/admin.py

Fixing the tests we broke

...
  needs: ['model:product']
...

front/tests/unit/models/orderline-test.js

...
  needs: ['serializer:order', 'model:orderline']
...

front/test/unit/serializers/order-test.js

...
  needs: ['model:orderline']
...

front/tests/unit/models/order-test.js

Questions?

Building a SPA webshop with DRF and Ember - part 2

By Emma

Building a SPA webshop with DRF and Ember - part 2

Intro to building a SPA webshop with DRF and Ember follow-up

  • 2,695