$ git clone git@github.com:dabernathy89/vue-spa-workshop.git
cd vue-spa-workshop
mv .env.example .env
mkdir database
touch database/scavengerhunt.sqlite
composer install
php artisan key:generate
php artisan migrate:fresh --seed
php -S localhost:8081
$ git reset --hard HEAD
$ git checkout origin/step-2
# Run this *after* we have walked through Step 2
In a single-page application, all interactions between the client and server after the initial page load happen without reloading the page (usually via AJAX).
JavaScript becomes responsible for:
Vue makes both of these approaches easy
Vue.js allows you to write HTML that automatically reacts to changes in your data.
Vue helps you avoid:
The core Vue library is supplemented by additional, official libraries and tools:
Home Page
- list user's scavenger hunts
- list scavenger hunts users have joined or can join
Create Scavenger Hunt Page
Scavenger Hunt Solutions Page
- owners can view submitted solutions and select a winner
Single Scavenger Hunt Page
- owners can add goals
- participants can add solutions
Each step has a corresponding branch in the Git repo. Between each step, we'll do some live coding.
When it's time to move on to the next step, we'll all clear our working directory and start with the same code.
$ git reset --hard HEAD
$ git checkout origin/step-2
Introduce Vue
// resources/views/layouts/app.blade.php
{{-- <script src="{{ asset('js/app.js') }}" defer></script> --}}
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script>
// resources/views/layouts/app.blade.php
@yield('js')
// resources/views/home.blade.php
@section('js')
@endsection
Introduce Vue (cont'd)
// resources/views/home.blade.php (near top)
<h1>Testing: @{{ title }}</h1>
// resources/views/home.blade.php
@section('js')
<script>
new Vue({
el: '#app',
data: {
title: "I'm using Vue.JS!",
}
});
</script>
@endsection
Start Vue-ifying Home Page
new Vue({
el: '#app',
data: {
ownedHunts: @json($owned_hunts),
}
});
Start Vue-ifying Home Page
@if (!$owned_hunts->isEmpty())
<a class="btn btn-primary" href="{{ route('hunt.create') }}">
<a v-if="ownedHunts.length" class="btn btn-primary" href="/hunts/create">
Create <i class="fas fa-plus-square"></i>
</a>
@endif
Start Vue-ifying Home Page
<ul class="list-group list-group-flush">
@foreach($owned_hunts as $hunt)
<li class="list-group-item">
<a href="{{ route('hunt.show', ['hunt' => $hunt->id]) }}">
{{ $hunt->name }}
</a>
</li>
@endforeach
<ul v-if="ownedHunts.length" class="list-group list-group-flush">
<li v-for="hunt in ownedHunts" class="list-group-item">
<a :href="'hunts/' + hunt.id">
@{{ hunt.name }}
</ul>
Start Vue-ifying Home Page
<div v-else class="card-body">
<p>It looks like you don't currently own any scavenger hunts. Create one now:</p>
<a class="btn btn-primary" href="/hunts/create">Create A Scavenger Hunt</a>
</div>
Continue Vue-ifying Home Page
Convert an HTML Form
// /resources/views/hunt/partials/join-hunt-button.blade.php
<form
action="{{ route('hunt.add_user', ['hunt' => $hunt->id, 'user' => auth()->id()]) }}"
method="POST">
@csrf
<button title="Join Scavenger Hunt" class="btn btn-secondary" type="submit">
Join <i class="fas fa-user-plus"></i>
</button>
</form>
// /resources/views/home.blade.php
@include('hunt.partials.leave-hunt-button')
<button
v-if="!hunt.includesCurrentUser && hunt.is_open"
@click="joinHunt(hunt.id)"
title="Join Scavenger Hunt"
class="btn btn-secondary">
Join <i class="fas fa-user-plus"></i>
</button>
Convert an HTML Form (cont'd)
// /resources/views/home.blade.php
new Vue({
el: '#app',
data: {
ownedHunts: @json($owned_hunts),
otherHunts: @json($other_hunts),
},
});
currentUserId: {{ auth()->id() }}
},
methods: {
joinHunt(id) {
axios.post('/hunts/' + id + '/users/' + this.currentUserId)
.then(function (response) {
console.log(response);
});
}
}
});
// /resources/views/layouts/app.blade.php
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
Convert an HTML Form (cont'd)
Your turn! Do the same refactor, but for the "Leave" button.
Hint - the URL is the same, but uses the DELETE method
Convert an HTML Form (cont'd)
Our refactor from HTML forms to Vue methods is working, but we're still missing some features:
Convert an HTML Form (cont'd)
// /resources/views/layouts/app.blade.php
@if (session('success'))
<div class="alert alert-success fade show" role="alert">
{{ session('success') }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
@endif
<div v-if="successMessage" class="alert alert-success fade show" role="alert">
@{{ successMessage }}
<button @click="successMessage = ''" type="button" class="close" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
Convert an HTML Form (cont'd)
// app/Http/Controllers/HuntController.php
return redirect()
->back()
->with('success', 'You successfully left the Scavenger Hunt "' . $hunt->name . '".');
return response()->json([
'successMessage' => 'You successfully left the Scavenger Hunt "' . $hunt->name . '".'
]);
Convert an HTML Form (cont'd)
// resources/views/home.blade.php
joinHunt(id) {
joinHunt(id, index) {
var vm = this;
axios.post('/hunts/' + id + '/users/' + this.currentUserId)
.then(function (response) {
console.log(response);
});
}
vm.successMessage = response.data.successMessage;
vm.otherHunts[index].includes_current_user = true;
window.scrollTo({top: 0});
});
}
// resources/views/home.blade.php
<li v-for="(hunt, index) in otherHunts">
Convert the "Create a Scavenger Hunt" page
Convert the "Create a Scavenger Hunt" page
<form action="{{ route('hunt.store') }}" method="POST">
@csrf
<form @submit.prevent="createHunt">
<div class="form-group">
<label for="name">Scavenger Hunt Name</label>
<input class="form-control" type="text" name="name" placeholder="My Cool Scavenger Hunt">
<input v-model="huntName" class="form-control" type="text" placeholder="My Cool Scavenger Hunt">
</div>
<input class="btn btn-primary" type="submit" value="Submit">
</form>
Convert the "Create a Scavenger Hunt" page (cont'd)
new Vue({
el: '#app',
data: {
successMessage: '',
huntName: '',
},
});
methods: {
createHunt: function () {
var vm = this;
axios.post('/hunts/create', {name: this.huntName})
.then(function (response) {
vm.successMessage = response.data.successMessage;
vm.huntName = '';
});
}
}
});
Convert the "Create a Scavenger Hunt" page (cont'd)
createHunt: function () {
var vm = this;
axios.post('/hunts', {name: this.huntName})
.then(function (response) {
vm.successMessage = response.data.successMessage;
vm.huntName = '';
});
}
Handling errors
this.errors = [];
}, function (error) {
var errors = error.response.data.errors;
for (field in errors) {
vm.errors = vm.errors.concat(errors[field]);
}
});
}
Convert the "Create a Scavenger Hunt" page (cont'd)
// resources/views/partials/errors.blade.php
@if ($errors->any())
<div class="alert alert-danger">
<ul class="mb-0">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
Handling errors
<div v-if="errors.length" class="alert alert-danger">
<ul class="mb-0">
<li v-for="error in errors">@{{ error }}</li>
</ul>
</div>
Freebie - convert the single Scavenger Hunt page for participants
Your turn - start converting the single Scavenger Hunt page for owners. You can choose one or more of these:
Migrating to Webpack
At this point all of the screens in our app have been converted to use Vue. However:
In order to solve these problems and move toward a real Single-page Application, we need to move our JavaScript out of Blade templates and into separate JavaScript files.
Migrating to Webpack
{{-- <script src="{{ asset('js/app.js') }}" defer></script> --}}
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.17/dist/vue.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="{{ mix('/js/app.js') }}"></script>
$ npm install
At this point you should install your JS dependencies with npm (or yarn):
Migrating to Webpack
We'll get started by reducing our `app.js` file to the bare minimum:
window.axios = require('axios');
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
window.Vue = require('vue');
Now you can run `npm run hot` to start watching for changes automatically:
$ npm run hot
We'll also edit `webpack.mix.js` to only compile JavaScript, and not SASS:
mix.js('resources/assets/js/app.js', 'public/js')
.sass('resources/assets/sass/app.scss', 'public/css');
Setting up Single File Components
With Laravel Mix set up, we have much more flexibility.
We can:
Setting up Single File Components (cont'd)
To get started, we will set up one parent Vue instance in `app.js` that will be shared among all of the pages. We'll also start referencing the Home page component.
Vue.component('home', require('./components/Home.vue'));
const app = new Vue({
el: '#app',
data: {
successMessage: '',
},
});
Setting up Single File Components (cont'd)
Next, we'll set up the Home Page single file component:
// resources/assets/js/components/Home.vue
<template>
... copy everything from the 'content' section in `home.blade.php` here
</template>
<script>
export default {
... copy everything inside of the `new Vue()` call here
}
</script>
Migrate to Single File Components (cont'd)
Our Blade file will contain the Vue component, and it will set up some data for it:
// resources/views/home.blade.php
@extends('layouts.app')
@section('content')
<home></home>
@endsection
@section('js')
<script>
window.currentUserId = {{ auth()->id() }};
window.ownedHunts = @json($owned_hunts);
window.otherHunts = @json($other_hunts);
</script>
@endsection
Parent-child Communication
Our success message no longer works, because it lives outside of the component.
Communication from components to their parents is handled via events. This requires:
Parent-child Communication (cont'd)
// resources/assets/js/app.js - event handler
methods: {
success(message) {
this.successMessage = message;
}
},
data: { successMessage: '' },
// resources/assets/js/components/Home.vue - event trigger
// this.successMessage = response.data.successMessage;
this.$emit('success', response.data.successMessage);
// resources/views/home.blade.php - event listener
@section('content')
<home @success="success"></home>
@endsection
Your turn - migrate the "Create a Scavenger Hunt" page into a single file component called `Create.vue`
You'll need to:
Freebie - migrating the additional pages to Single File Components
Setting up Vue Router
Vue Router is a separate package that needs to be installed independently:
$ npm install vue-router
We'll then pull it in to our main `app.js` file:
// resources/assets/js/app.js
// After Vue is imported:
import VueRouter from 'vue-router';
Vue.use(VueRouter)
Setting up Vue Router (cont'd)
To set up Vue Router, we need to:
Let's start with just the home page and the "Create a Scavenger Hunt" page.
Setting up Vue Router (cont'd)
// resources/assets/js/app.js
import Home from './components/Home.vue';
import Create from './components/Create.vue';
const router = new VueRouter({
mode: 'history',
routes: [
{ path: '/', component: Home },
{ path: '/hunts/create', component: Create }
]
});
const app = new Vue({
router: router,
...
We'll define our Router & routes above our root Vue instance so that we can pass the Router object to it.
Setting up Vue Router (cont'd)
// routes/web.php
Route::get('/hunts/create', 'HuntController@create')->name('hunt.create');
We need to point the 'Create' route at the home URL.
Route::get('/hunts/create', 'HomeController@index')->name('hunt.create');
In our `home.blade.php` file, we'll replace the component tag with the special `<router-view>` tag:
// resources/views/home.blade.php
<home @success="success"></home>
<router-view @success="success"></router-view>
Setting up Vue Router (cont'd)
// routes/web.php
Route::get('/hunts/create', 'HuntController@create')->name('hunt.create');
We need to point the 'Create' route at the home URL.
Route::get('/hunts/create', 'HomeController@index')->name('hunt.create');
In our `home.blade.php` file, we'll replace the component tag with the special `<router-view>` tag:
// resources/views/home.blade.php
<home @success="success"></home>
<router-view @success="success"></router-view>
Navigation
Normal links will cause full page reloads. Vue Router provides the special <router-link> element to make it easy to generate links.
// resources/assets/js/components/Home.vue
<a v-if="ownedHunts.length" class="btn btn-primary" href="/hunts/create/">
Create <i class="fas fa-plus-square"></i>
</a>
<router-link
v-if="ownedHunts.length"
class="btn btn-primary"
:to="{ path: '/hunts/create'}">
Create <i class="fas fa-plus-square"></i>
</router-link>
Navigation
You can also navigate programmatically:
// resources/assets/js/components/Create.vue
// After successfully creating a new Scavenger Hunt:
this.$router.push({path: '/'});
Fetching data
We don't want to rely on Blade to pass data to Vue, so we'll set up some API endpoints to grab Hunt data:
// routes/web.php
Route::group(['middleware' => ['auth']], function () {
Route::get('/api/ownedHunts', 'HuntController@ownedHunts')->name('hunt.owned');
Route::get('/api/otherHunts', 'HuntController@otherHunts')->name('hunt.other');
...
// app/Http/HuntController.php
public function ownedHunts()
{
return auth()->user()->ownedHunts;
}
public function otherHunts()
{
return Hunt::where('owner_id', '!=', auth()->id())->get();
}
Fetching data
We can hook into Vue Router to fetch the data before the route is entered:
// resources/assets/js/components/Home.vue
beforeRouteEnter (to, from, next) {
let ownedHunts;
let otherHunts;
axios.get('/api/ownedHunts').then(response => {
ownedHunts = response.data;
return axios.get('/api/otherHunts')
}).then(response => {
otherHunts = response.data;
next(vm => {
vm.ownedHunts = ownedHunts;
vm.otherHunts = otherHunts;
});
});
},