PERSISTANCE WS
Text
(JON'S DAD DOES NOT USE EMBER)
- Laurie Voss
JS Conf US 2018
* uses ember-cli-update
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')
});
//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
😬
@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
}
@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
}
@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
...
}
{
"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}";
}
...
}
Text
@Entity
class User {
...
@OnCreatePostCommit
fun onCommitBook() {
this.emailSevice.sendWelcome(this)
}
...
}
{
"data": {
"type": "users",
"id": "1",
"attributes": {
"name": "jkusa"
},
"relationships": {
"reviews": {
"data": [
{
"type": "reviews",
"id": "1"
}
]
}
}
}
}
GET api/v1/users/1
type
identifier
attributes
relationships
{
"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
{
"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
$ 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
$ ember new book-app
/ // redirect to reviews
/reviews/ // list all reviews for a user
/reviews/:id // show specific review
/reviews/new // create a new review
/ (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
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
$ ember g model user
$ ember g model reviews
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
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
/**
* 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(),
});
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}}
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)
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
<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
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
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)
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