+

STAYING

WITH

By: Jon Kilroy

Productive

 

11 Years

HAPPY NEW YEAR

TODOIST

TODOIST

EVERYONE ❤️ PRODUCTIVITY

HOW WE BUILD APPS

LAST 4 YEARS

PATTERN

PERSISTANCE WS

A FRAMEWORK FOR AMBITIOUS WEB DEVELOPERS

Ember.JS 🤔 OK GRADMPA...

Text

JON

JON'S DAD

(JON'S DAD DOES NOT USE EMBER)

Ember.JS 7 Years Old

Ember.JS 

Knockout.JS

 

AngularJS 1

- Laurie Voss

   @seldo

"...Ember is seeing really healthy growth."

   JS Conf US 2018

WHY IS EMBER STILL AROUND?

IT SPARKS JOY

GOALS 🎯 & VALUES 🧮

GOAL 🎯

"BIGGEST SET OF OPINIONS IN JS"

 

VALUES 🧮

  • PROVIDE SHARED ABSTRACTIONS 👫
  • ELIMINATE DESICIONS 🤔
  • CONVENTION OVER CONFIGURATION 🙏 
  • GREAT TOOLING 🛠
  • TESTING 🧪

 

 

YOU WON'T GET THE HOTTEST FEATURES RIGHT AWAY

YOU WILL GET BEST CURATED IDEAS 

STABILITY WITHOUT STAGNATION

6 WEEK RELEASE CYCLE

LONG TERM SUPPORT (LTS) EVERY 4 RELASES

EMBER ECOSYSYEM

  • BUILD PIPELINE
  • ADDONS
  • GENERATORS
  • TEST RUNNER
  • DEVELOPMENT SERVING
  • UPDATES*
  • CODE MODS*

* uses ember-cli-update

Ember CLI

EMBER 101

  • Router - maps url to route object

  • Route  -  fetches data (model)

  • Model  - describes what a resource looks like

  • Controller - holds route state (query params)

  • Service - cross cutting logic

  • Component - logical set of content & behavior

 

CREDIT: https://guides.emberjs.com/release/getting-started/core-concepts/

Ember Data Store

//models/person.js
import DS from 'ember-data';

export default DS.Model.extend({
  name:      DS.attr('string'),
  birthday:  DS.attr('date')
});

EMBER DATA

EMBER DATA

//CREATE
const person = this.store.createRecord('person', { name: 'jon' ); 
person.save();

//READ
const person = this.store.findRecord('person', 1); 
const people = this.store.findRecord('person'); 

//UPDATE 
person.set('birthday', '07/22/1982');
person.save();

//DELETE
person.deleteRecord();
person.save();


//UNDO
person.set('birthday', '07/22/1982');
person.rollbackAttributes();

Stand up {json:api} & GraphQL web services backed by JPA annotated models in 4 simple steps


elide.io

CONFESSION

😬

JavaScript

Virtual

Machine

@Entity
@Include(rootLevel = true, type = "posts")
class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long = 0

    var title: String = ""

    var body: String = ""

    @ManyToOne
    var author: User? = null
}

1. DEFINE A MODEL

@CreatePermission(expression = "is the author")
@ReadPermission(expression   = "anyone")
@UpdatePermission(expression = "is the author")
@DeletePermission(expression = "is the author")
class Review {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long = 0


    @UpdatePermission(expression = "nobody")
    var title: String = ""

    var body: String = ""

    @ManyToOne
    var author: User? = null
}

2. SECURE IT

@CreatePermission(expression = "is the author")
@ReadPermission(expression   = "anyone")
@UpdatePermission(expression = "is the author")
@DeletePermission(expression = "is the author")
@Include(rootLevel = true, type = "posts")
class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long = 0
    ...
}

3. EXPOSE IT

4. DEPLOY & QUERY

{
    "data": {
        "type": "posts",
        "id": "1",
        "attributes": {
            "title": "My Awesome Blog Post",
            "rating": 5,
            "text": "Elide is great!"
        },
        "relationships": {
            "author": {
                "data": {
                    "type": "users",
                    "id": "1"
                }
            }
        }
    }
}

Text

@Entity
class Person {
    ...
    @Transient
    @ComputedAttribute
    fun getFullName(requestScope: RequestScope): String {
        return "${this.firstName} ${this.lastName}";
    }
    ...
}

COMPUTED ATTRIBUTES

Text

@Entity
class User {
    ...
   @OnCreatePostCommit
   fun onCommitBook() {
     this.emailSevice.sendWelcome(this)
   }
   ...
}

LIFECYLCE HOOKS

TWO OPTIONS

OR

OR BOTH!

EMBER DATA

❤️

JSON-API

application/vnd.api+json

{
    "data": {
        "type": "users",
        "id": "1",
        "attributes": {
            "name": "jkusa"
        },
        "relationships": {
            "reviews": {
                "data": [
                    {
                        "type": "reviews",
                        "id": "1"
                    }
                ]
            }
        }
    }
}

GET api/v1/users/1

type
identifier

attributes

 

 

relationships

COMPLEX FILTERING

{
    "data": {
        "type": "posts",
        "id": "1",
        "attributes": {
            "title": "My First Blog Post",
            "body": "Hello World"
         },
         "relationships": {
             "reviewer": {
                 "data": {
                     "type": "users",
                     "id": "1"
                 }
             }
         }
    }
}

GET api/v1/users/post?[posts]=title==*Blog*

{
    "data": {
        "type": "users",
        "id": "1",
        "attributes": {
            "name": "jkusa"
        },
        "relationships": {
            "reviews": {
                "data": [
                    {
                        "type": "reviews",
                        "id": "1"
                    }
                ]
            }
        }
    },
    "included": [
        {
            "type": "reviews",
            "id": "1",
            "attributes": {
                "title": "My First Blog Post",
                "body": "Hello World"
            },
            "relationships": {
                "reviewer": {
                    "data": {
                        "type": "users",
                        "id": "1"
                    }
                }
            }
        }
    ]
}

GET api/v1/users/1?include=posts

FETCH RELATED ENTITIES

{
    "data": {
        "type": "users",
        "id": "1"
    },
    "included": [
        {
            "type": "posts",
            "id": "1",
            "attributes": {
                "title": "My Great Blog Post",

            }
        }
    ]
}

GET api/v1/users/1?include=posts&fields[posts]=title

FETCH SPECIFIC FIELD SET

LET'S BUILD AN APP

Reads

Great

THE WEB SERVICE

NO CLI
😞

YET

$ gradle init
plugins {
    id("org.jetbrains.kotlin.jvm").version("1.3.11")
}

repositories {
    // Use jcenter for resolving your dependencies.
    jcenter()
}

dependencies {
    // Use the Kotlin JDK 8 standard library.
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    implementation("com.yahoo.elide:elide-standalone:4.2.11")
    implementation("com.h2database:h2:1.4.197")
}

build.gradle.kts

package io.jkusa.book.api

import com.yahoo.elide.security.checks.prefab.Role
import com.yahoo.elide.standalone.ElideStandalone
import com.yahoo.elide.standalone.config.ElideStandaloneSettings

class AppSettings: ElideStandaloneSettings {
    override fun getCheckMappings() = mutableMapOf(
        "any user" to Role.ALL::class.java,
        "nobody" to Role.NONE::class.java
    )

    override fun getFilters() = mutableListOf(CorsFilter::class.java)

    override fun getPort() = 4080

    override fun getModelPackageName() = "io.jkusa.book.api.models"
}

fun main(args: Array<String>) {
    val appSettings = AppSettings()
    val elide = ElideStandalone(appSettings)
    elide.start()
}

App.kt

<?xml version='1.0' encoding='utf-8'?>
<!DOCTYPE hibernate-configuration PUBLIC
        "-//Hibernate/Hibernate Configuration DTD 3.0//EN"
        "http://www.hibernate.org/dtd/hibernate-configuration-3.0.dtd">

<hibernate-configuration>

    <session-factory>

        <!-- Temporary in memory db settings -->
        <property name="connection.driver_class">org.h2.Driver</property>
        <property name="connection.url">jdbc:h2:mem:db1;DB_CLOSE_DELAY=-1;MVCC=TRUE</property>
        <property name="connection.username">dbuser</property>
        <property name="connection.password"/>
        <property name="connection.pool_size">1</property>

        <!-- Automatically create tables for testing -->
        <property name="hbm2ddl.auto">create</property>

        <!-- Log sql for debugging -->
        <property name="show_sql">true</property>

        <property name="dialect">org.hibernate.dialect.H2Dialect</property>
        <property name="current_session_context_class">thread</property>

    </session-factory>

</hibernate-configuration>

hibernate.cfg.xml

package io.jkusa.book.api.models

import com.yahoo.elide.annotation.Include
import com.yahoo.elide.annotation.SharePermission
import javax.persistence.*
import javax.validation.constraints.*

@Entity
@Include(rootLevel = true, type = "users")
@SharePermission
class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long = 0

    @get:NotBlank(message = "Name may not be blank")
    @get:Size(min = 2, max = 32, message = "Name must be between 2 and 32 characters long")
    var name: String = ""

    @OneToMany(mappedBy = "reviewer")
    @get:NotNull
    var reviews: Set<Review>? = HashSet()
}

User.kt

package io.jkusa.book.api.models

import com.yahoo.elide.annotation.*
import javax.persistence.*
import javax.validation.constraints.*

@Entity
@Include(rootLevel = true, type = "reviews")
@SharePermission
class Review {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long = 0

    @NotNull
    @get:Size(min = 2, max = 32, message = "ISBN must be between 2 and 32 characters long")
    @get:UpdatePermission(expression = "nobody")
    var isbn: String? = null

    @get:Min(0)
    @get:Max(5)
    var rating: Float = 0.0f

    @get:NotEmpty
    @get:Size(min = 2, max = 32, message = "Text must be between 2 and 32 characters long")
    var text: String = ""

    @ManyToOne
    var reviewer: User? = null
}

Review.kt

THAT'S IT

🤯

THE CLIENT

$ ember new book-app

CREATE THE EMBER APP

DESIGN

DESIGN THE ROUTES

/                          // redirect to reviews

/reviews/           // list all reviews for a user

/reviews/:id      // show specific review

/reviews/new   // create a new review

DESIGN THE ROUTES

/ (application)

/reviews

/(index)

  or

/:id (review id)

  or

/new

$ ember g route reviews
$ ember g route reviews/index
$ ember g route reviews/review
$ ember g route revies/new

CREATE THE ROUTES

CREATE THE ROUTES

import EmberRouter from '@ember/routing/router';
import config from './config/environment';

const Router = EmberRouter.extend({
  location: config.locationType,
  rootURL: config.rootURL
});

Router.map(function() {
  this.route('reviews', function() {
    this.route('new');
    this.route('review', { path: '/:review_id' });
  });
});

export default Router;

app/router.js

MODELS

$ ember g model user
$ ember g model reviews

CREATE THE MODELS

import DS from 'ember-data';
import { attr, hasMany } from '@ember-decorators/data';

const { Model } = DS;

export default class UserModel extends Model {
  @attr name;
  @hasMany('review') reviews;
}

app/models/user.js

decorator

😮

😘

decorator

import DS from 'ember-data';
import { attr, belongsTo } from '@ember-decorators/data';

const { Model } = DS;

export default class ReviewModel extends Model {
  @attr isbn;
  @attr rating;
  @attr text;
  @belongsTo('user') reviewer
}

app/models/review.js

CREATE MOCKS

export default function() {
  this.urlPrefix = 'http://localhost:4080';
  this.namespace = '/api/v1';  
  //this.timing = 400;

  this.get('/users');
  this.get('/users/:id');

  this.post('/reviews');
  this.get('/reviews');
  
  this.get('/reviews/:id');
  this.patch('/reviews/:id');
  this.del('/reviews/:id');
}

mirage/config.js

CREATE FACTORIES

/**
 * mirage/factories/user.js
 */
import { Factory, faker } from 'ember-cli-mirage';

export default Factory.extend({
  name: () => faker.internet.userName()
});


/**
 * mirage/factories/review.js
 */
import { Factory, faker } from 'ember-cli-mirage';

const BOOKS = [
  9780345539809,
  9781451648539,
  9780701161415,
  9780307887443
];

export default Factory.extend({
  rating: () => faker.random.number() % 5,

  isbn: id => BOOKS[id],

  text: () => faker.lorem.paragraphs(),
});

greatreads.io/
(application route)

import Route from '@ember/routing/route';

const USER_ID = 1;

export default class ApplicationRoute extends Route {
  model() {
    return this.store.findRecord('user', USER_ID)
  }
}

app/routes/application.js

  GET api/v1/users/1

<div class="application">

  <header class="application__header">

    {{#link-to "application"}}
      <div class="application__header-left">
        <img class="application__header-icon" src="logo.png" alt="logo">
        <span class="application__header-great">Great</span><span class="application__header-reads">Reads</span>
      </div>
    {{/link-to}}

    <div class="application__header-right">
      {{fa-icon "user-circle"}}
      <span>{{model.name}}</span>
    </div>

  </header>

  <main>
    {{outlet}}
  </main>

  <footer class="application__footer">
    <span>Created By: @jkusa</span>
  </footer>

</div>

app/templates/application.hbs

in-app navigation

 

font awesome component

user name from model

where to mount sub routes

{{outlet}}

greatreads.io/reviews/

(reviews list view)

 

import Route from '@ember/routing/route';
import { inject as service } from '@ember-decorators/service';

export default class ReviewsIndexRoute extends Route {
  @service library;

  async model() {
    const user = this.modelFor('application') 
   
    const reviews = await user.reviews;      
   
    const lookups = reviews.map(review => {
      return this.library.findByIsbn.perform(review.isbn)
        .then(book =>  { book, review })
    });
    return Promise.all(lookups);
  }
}

app/routes/index.js

inject service

fetch user's reviews

user model

look up book info by isbn

(NO APPLICATION ID REQUIRED)

SERVICE

import Service from '@ember/service';
import { task } from 'ember-concurrency-decorators';
import fetch from 'fetch';

export default class LibraryService extends Service {

  @task
  findByIsbn = function *(isbn) {
    const url = `https://openlibrary.org/api/books?bibkeys=ISBN:${isbn}&format=json&jscmd=data`;
    const res = yield fetch(url).then(res => res.json());
    const book = res[`ISBN:${isbn}`];
    return {
      isbn,
      title: book.title,
      author: book.authors.firstObject.name,
      publishDate: book.publish_date
    };
  }
}

app/service/library.js

cross-cutting  singleton

task decorator

generator function

...
export default class LibraryService extends Service {

  @task
  search = function *(term) {
    const controller = new AbortController();
    const signal = controller.signal;
    try {
      const url = `http://openlibrary.org/search.json?q=${term}&limit=20`;
      const books = yield fetch(url, { signal }).then(res => res.json());
      return books.docs.filter(book => book.isbn).map(book => {
        return {
          isbn: book.isbn.firstObject,
          title: book.title,
          author: book.author_name,
          publishDate: book.publish_date
        }
      });
    } finally { controller.abort(); }
  }
...

app/service/library.js

abort xhr

allows xhr abort

  • Provides Task primitive based on JS Generators

  • Unlike Promises, support cancelation

  • Modifiers such as .drop() & .restartable()

ember-concurrency

<div class="reviews-index">
  <div>
    <button>
      {{#link-to "reviews.new"}}
        New Review
      {{/link-to}}
    </button>
  </div>

  <div class="reviews-index__reviews">
    <ul>
      {{#each model as | item |}}
        <li class="reviews-index__review">
          {{#link-to "reviews.review" item.review.id }}
            <div class="reviews-index__review-container">
              <img class="reivews-index__img" src="http://covers.openlibrary.org/b/isbn/{{item.review.isbn}}-S.jpg" alt="cover">
              <div class="reviews-index__book-info">
                <div class="reviews-index__book-title">{{item.book.title}}</div>
                <div>{{item.book.author}}</div>
              </div>
              {{star-rating item.review.rating
                class="reivews-index__rating"
              }}
              <div class="review-index__review-text">
                {{item.review.text}}
              </div>
            </div>
          {{/link-to}}
        </li>
      {{/each}}
    </ul>
  </div>
</div>

app/templates/reviews/index.js

nav link to new review

iterate over all reviews

nav link to specific review

star rating component

display the model elements

greatreads.io/reviews/new
(new review route)

import Route from '@ember/routing/route';

export default class ReviewsNewRoute extends Route {
  model() {
    //create new review model
    const reviewer = this.modelFor('application'); //user model
    return this.store.createRecord('review', { reviewer });
  }
}

app/routes/reviews/new.js

<h1>New Review</h1>
<div class="reviews-new__search-container">
  {{fa-icon "search" class="reviews-new__search-icon"}}
  {{#power-select
    onchange=(action "setBook")
    search=(perform searchTask)
    placeholder="Serach Books"
    as |book|
  }}
    <div class="reviews-new__book">
      <img class="reviews-new__search-img" 
          src="http://covers.openlibrary.org/b/isbn/{{book.isbn}}-S.jpg" 
          alt="cover img"
      >
      <div class="reviews-new__book-info">
        <div class="reviews-new__book-title">{{book.title}}</div>
        <div>{{book.author}}</div>
      </div>
    </div>
  {{/power-select}}
</div>

app/template/reviews/new.hbs

callback when book is selected

custom search logic

select component

provide custom book layout

import Controller from '@ember/controller';
import { action } from '@ember-decorators/object';
import { timeout } from 'ember-concurrency';
import { task } from 'ember-concurrency-decorators';
import { inject as service } from '@ember-decorators/service';

export default class ReviewsNewController extends Controller {

  @service library;

  @task
  searchTask = function *(term) {
    yield timeout(400);
    return this.library.search.perform(term)
  }

  @action
  async setBook({isbn}) {
    const { model } = this;
    model.set('isbn', isbn);
    await model.save();
    this.transitionToRoute('reviews.review', model.id);
  }
}

app/controller/reviews/new.js

inject library service

save model to db (POST)

debounce for 400ms

nav to specific review

 template callback handler

greatreads.io/reviews/:id

(specific review view)

 

import Route from '@ember/routing/route';
import { inject as service } from '@ember-decorators/service';

export default class ReviewsReviewRoute extends Route {
  @service library;

  async model({review_id}) {
    const { store, library } = this;
    const review = await store.findRecord('review', review_id);
    const book = await library.findByIsbn.perform(review.isbn);
    return { book, review };
  }
}

app/routes/reviews/review.js

path param value

<h1>Review</h1>

{{book-review
  book=model.book
  review=model.review
  onUpdate={{action "updateReview"}}
}}

{{#if model.review.hasDirtyAttributes}}
  <button
    class="reviews-review__save"
    onClick={{action "saveReview"}}
  >
    Save
  </button>

  <button
    class="reviews-review__save"
    onClick={{action "revertReview"}}
  >
    Revert
  </button>
{{/if}}

app/templates/reviews/review.hbs

book component

show buttons if review is dirty

import Controller from '@ember/controller';
import { action } from '@ember-decorators/object';

export default class ReviewsReviewController extends Controller {
  @action
  updateReview(updates) {
    this.model.review.setProperties(updates);
  }

  @action
  revertReview() {
    this.model.review.rollbackAttributes()
  }

  @action
  saveReview() {
    this.model.review.save();
  }
}

app/controller/reviews/review.hbs

rollback to db version

update the model

save updates to db (PATCH)

TEST

import { module, test } from 'qunit';
import { visit, click, currentURL, findAll } from '@ember/test-helpers';
import { setupApplicationTest } from 'ember-qunit';
import setupMirage from 'ember-cli-mirage/test-support/setup-mirage';
import defaultScenario from '../../mirage/scenarios/default';
import { selectChoose, selectSearch } from 'ember-power-select/test-support';

module('Acceptance | new review', function(hooks) {
  setupApplicationTest(hooks);
  setupMirage(hooks);

  hooks.beforeEach(function() {
    defaultScenario(this.server);
  });

  test('visiting /new-review', async function(assert) {
    await visit('/');
    assert.equal(currentURL(), '/reviews');
    assert.equal(findAll('.reviews-index__review').length, 4);

    await click('.reviews-index__new-btn > a');
    assert.equal(currentURL(), '/reviews/new',);

    await selectSearch('.reviews-new__search-container', 'the hobit');
    await selectChoose('body', '.ember-power-select-option', 1);
    assert.equal(currentURL(), '/reviews/5');
    assert.dom('.book-details__title').hasText('The Hobbit (Collins Modern Classics)');

    await visit('/');
    assert.equal(currentURL(), '/reviews');
    assert.equal(findAll('.reviews-index__review').length, 5);
  });
});

tests/acceptance/new-review-test.js

nav to new route

verify initial state

select select book

verify new review

verify list view is updated

DONE

IN JUST ~28 STEPS

DEMO

(maybe?)

👋

👋

STRONG OPINIONS & CONVENTIONS

❄️

stay hungry stay productive

THANKS

ember-elide

By jkusa

ember-elide

  • 603