www.kylecoberly.com
Make you want to try this pattern
Application
Inputs
Outputs
Component
Component
Component
Application
Inputs
Outputs
Component
Component
Component
Well-architected applications are maintainable
The primary driver of maintainability is testability
The primary driver of testability is purity
Component
Component
Component
Component
<header>
<h1><a href="/"><img src="" alt="Logo" /></a><h1>
<nav>
<ul>
<li><router-link :to="{name: 'products'}">Products</router-link></li>
<li><router-link :to="{name: 'team'}">Team</router-link></li>
</ul>
</nav>
</header>
<main>
<h2>Our Products</h2>
<p>Lorem ipsum dolor sit amet, consectetur adipiscing</p>
<some-widget />
<another-widget>
<some-nested-widget />
</another-widget>
</main>
<footer>
<nav>
<ul>
<li><router-link :to="{name: 'products'}">Products</router-link></li>
<li><router-link :to="{name: 'team'}">Team</router-link></li>
<li><router-link :to="{name: 'about'}">About</router-link></li>
<li><router-link :to="{name: 'investors'}">Investors</router-link></li>
<li><router-link :to="{name: 'careers'}">Careers</router-link></li>
</ul>
</nav>
<small>© 2008 BarkWire</small>
</footer>
<application-header />
<router-view />
<application-footer />
<header>
<h1><a href="/"><img src="" alt="Logo" /></a><h1>
<nav>
<ul>
<li><router-link :to="{name: 'products'}">Products</router-link></li>
<li><router-link :to="{name: 'team'}">Team</router-link></li>
</ul>
</nav>
</header>
Parent
Child
State
Events
Parent
Child
State
Functions
<template>
<counter
:count="count"
@increment="increment"
/>
</template>
<script>
export default {
components: {
counter
},
data(){
return {
count: 1
};
},
methods: {
increment(){
this.count++;
}
}
};
</script>
<template>
<p>{{count}}</p>
<button @click="@emit('increment')"></button>
</template>
<script>
export default {
props: {
count: Number
}
};
</script>
describe("Outer Component", function(){
describe("#increment", () => {
it("increments", () => {
const component = mount(OuterComponent)
assert.equal(component.vm.count, 1)
component.vm.increment()
assert.equal(component.vm.count, 2)
})
});
});
describe("Counter", function(){
describe("render", () => {
it("shows the current count", () => {
const component = mount(Counter, {
propsData: {count: 2}
});
assert.equal(component.text(), "2")
});
});
describe("#increment", () => {
it("increments", () => {
const component = mount(Counter);
component.find("button").trigger("click");
assert.ok(component.emitted().increment);
});
});
});
<template>
<counter
:count="count"
:increment="increment"
/>
</template>
<script>
export default {
components: {
counter
},
data(){
return {
count: 1
};
},
methods: {
increment(){
this.count++;
}
}
};
</script>
<template>
<p>{{count}}</p>
<button @click="increment"></button>
</template>
<script>
export default {
props: {
count: Number,
increment: Function
}
};
</script>
describe("Outer Component", function(){
describe("#increment", () => {
it("increments", () => {
const component = mount(OuterComponent)
assert.equal(component.vm.count, 1)
component.vm.increment()
assert.equal(component.vm.count, 2)
})
});
});
describe("Counter", function(){
describe("#increment", () => {
it("increments", () => {
const incrementStub = () => 2;
const component = mount(Counter, {
propsData: {
count: 1,
increment: incrementStub
}
})
assert.equal(component.text(), "1");
component.find("button").trigger("click");
assert.equal(component.text(), "2");
})
});
});
it("increments", () => {
const component = mount(Counter);
component.find("button").trigger("click");
assert.ok(component.emitted().increment);
});
<counter
:count="count"
@increment="increment"
/>
<counter
:count="count"
:increment="increment"
/>
it("increments", () => {
const incrementStub = () => 2;
const component = mount(Counter, {
propsData: {
count: 1,
increment: incrementStub
}
});
assert.equal(component.text(), "1");
component.find("button").trigger("click");
assert.equal(component.text(), "2");
});
Parent
Child
State
Events
Service
State
Events
<template>
<form @submit="sendMessage">
<div v-if="loadingService.isLoading">
<loading-spinner />
</div>
</form>
</template>
<script>
export default {
data: () => ({isLoading: this.$store.services.loading.isLoading});
};
</script>
<template>
<button @click="toggleMode">Toggle Mode</button>
</template>
<script>
export default {
methods: {
toggleMode(){
this.$store.services.mode.dispatch("toggleMode");
}
}
};
</script>
import Vue from "vue";
import Vuex from "vuex";
import loadingService from "../../src/services/loading";
Vue.use(Vuex);
describe("Form", function(){
describe("loading spinner", () => {
beforeEach(){
this.store = new Vuex.Store();
this.store.registerModule(["services", "loading"], loadingService);
this.component = mount(Form, {store: this.store});
}
it("doesn't display when data isn't loading", () => {
this.component.$store.services.loading.state.isLoading = false;
assert.ok(this.component.find(".loading-spinner").exists());
});
it("does display when data is loading", () => {
this.component.$store.services.loading.state.isLoading = true;
assert.ok(this.component.find(".loading-spinner").exists());
});
});
});
App.vue
DogList.vue
DogDetail.vue
Barkwire
Panzer is a Big Galoot!
Copyright 2018, Barkwire PBC
NESTED
Barkwire
Panzer is a Big Galoot!
Copyright 2018, Barkwire PBC
FLAT
Dogs > Panzer
Barkwire
Copyright 2018, Barkwire PBC
Dogs
const router = new VueRouter({
routes: [{
path: "/dogs",
component: DogsList,
children: [{
path: "",
component: DogsListIndex
},{
path: ":id",
component: DogDetail
}
]
}
]
});
const router = new VueRouter({
routes: [{
path: "/dogs",
component: DogsList,
},{
path: "/dogs/:id",
component: DogDetail
}
]
});
https://barkwire.com/dogs
https://barkwire.com/dogs/1
App.vue
DogList.vue
DogDetail.vue
https://barkwire.com/dogs/1
All routes
All /dogs routes
All /dogs/:id routes
Barkwire
Panzer is a Big Galoot!
Copyright 2018, Barkwire PBC
Dogs > Panzer
Barkwire
Panzer is a Big Galoot!
Copyright 2018, Barkwire PBC
Dogs > Panzer
https://barkwire.com/dogs/1
https://barkwire.com/dogs/1
Store
Actions
View
Component
State
Events
State
<template>
<dog-profile
dog="dog"
updateDate="updateDog"
/>
</template>
<script>
export {
created(){
return this.$store.dogs.dispatch("fetch", this.id);
},
computed: {
id(){
return this.$route.params.id;
},
dog(){
return this.$store.dogs.getters.read(this.id);
}
},
methods: {
updateDog(dog){
return this.$store.dogs.dispatch("update", dog);
}
}
};
</script>
Store
Data Module
Services Module
Service Module
Service Module
Actions
State
CRUD
Model
Data Module
Data Module
export default new Vuex.Store({
state: {
dogs: []
},
mutations: {
setDogs(state, dogs){
return state.dogs = dogs;
},
updateDog(state, updatedDog){
let dog = state.dogs.find(dog => updatedDog.id);
dog = updatedDog;
}
},
actions: {
async fetchDogs({commit}, dogs){
const dogs = await Dog.fetchAll();
return commit("setDogs", dogs);
},
async updateDog({commit}, dog){
const dog = await dog.save();
return commit("updateDog", dog);
}
}
});
Model
Store
Module
CRUD
Model
class Dog {
constructor(data){
this.id = +data.id;
this.properties = ["name", "breed", "ownerId"]
this.modelName = "dog";
this.data = this.normalize(data);
}
normalize(data){
return this.properties.reduce((modelData, property) => {
modelData.push(data[property]);
return data;
}, []);
}
fetch(){}
static fetchAll(){}
destroy(){}
save(){}
}
Building a model
class Model {
constructor(data){
this.id = +data.id;
}
normalize(data){
return this.properties.reduce((modelData, property) => {
modelData.push(data[property]);
return data;
}, []);
}
fetch(){}
static fetchAll(){}
destroy(){}
save(){}
}
class Dog extends Model {
constructor(data){
super(data);
this.properties = ["name", "breed", "ownerId"]
this.modelName = "dog";
this.data = this.normalize(data);
}
}
Extracting Common Behavior
<template>
<form @submit="addDog">
<label>Name</label>
<input name="name" :value="name" />
<input type="submit" value="Add Dog" />
</form>
</template>
<script>
import Dog from "../models/dog";
export default {
methods: {
addDog(){
this.$emit("addDog", new Dog({
name: this.name
}));
}
}
};
</script>
Using Models to Create
import Dog from "../models/dog";
export default {
state: {
dogs: []
},
mutations: {
setDogs(state, dogs){
return state.dogs = dogs;
}
}
actions: {
async fetchAll({commit}){
const dogs = Dog.fetchAll();
return commit.setDogs(dogs);
}
}
}
Using Models to Retrieve
<template>
<div>
<h2>Dog Name</h2>
<p>{{dog.data.name}}</p>
</div>
</template>
<script>
export default {
props: {
dog: Object
}
};
</script>
Using Model Data
Adapter
Serializer
Deserializer
Model
API
Adapter
Serializer
Deserializer
Model to Save
Saved Model
Internal Format
External Format
External Format
Internal Format
POST Request
POST Response
export default {
serialize(model){
return JSON.stringify(model);
},
deserialize(data){
return typeof data === "string"
? JSON.parse(data)
: data;
}
};
import Serializer from "./serializer";
class Adapter {
constructor(modelName){
this.serializer = Serializer;
this.modelName = modelName;
this.baseUrl = process.env.BASE_URL;
this.buildUrl.bind(this);
this.serialize.bind(this);
this.deserialize.bind(this);
}
buildUrl(){
return `${this.baseUrl}/${pluralize(this.modelName)}`;
}
serialize(model){
return this.serializer.serialize(model);
}
deserialize(data){
return this.serializer.deserialize(data);
}
save(model){
return fetch(this.buildUrl(), {
method: "POST",
"Content-Type": "application/json",
body: this.serialize(model)
}).then(response => response.json())
.then(response => this.serializer.deserialize(response));
}
}
import Adapter from "./adapter";
class Dog extends Model {
constructor(data){
super(data);
this.adapter = new Adapter("dog");
}
save(){
return this.adapter.save()
}
}
{
actions: {
async save({commit}, dog){
const dog = new Dog(dog);
const savedDog = await dog.save();
return commit("save", savedDog);
}
}
}
Increment!
How much?
42
Increment!
How much?
42