Last time on our series, we set up the structure for the wizard UI. We also setup our frontend workflow.
Today, we'll immerse ourselves in building out the add-team
and add-personal
components. We'll also setup modal dialogs for various actions in our wizard.
Disclaimer
As started previously, this tutorial is not an introduction to Vue or Vuex.
We're assuming a basic understanding of Vue (or any other component based framework) and Vuex (or an alternative state management solution).
If you'd like a friendlier introduction to Vue and Vuex, please visit these links:
Briefing
Previously in this series, we started constructing the UI for our wizard. We also applied some custom styling with preprocessors and set up our development workflow.
Today, we'll get our hands dirty building modal dialogs for our Wizard. We will leverage the Bootstrap CSS framework and Vue 2. We'll also dig into the state object and stretch our limits.
Difficulty
- Advanced
Requirements
- PHP version 5.6.4 or greater
- Composer version 1.4.1 or greater
- Lucid Laravel version 5.3.*
- Yarn package manager
- Previous code on the Github repository.
- A little patience and love for new ideas.
What Will I Learn?
- Working with Shared Components in Vue.
- Building the Add Personal Component.
- Building the Add Team Component.
- Setting Up Our Team Selection Modal Dialog.
1. Working with Shared Components in Vue.
Keeping a suite of shared components is a good software engineering practice. It helps keep things simple, easy to understand and can speed up our development speed exponentially. All we need to do is to keep these shared components at a location where they can be easily accessed by other parts of our application.
We'll add a shared component to our codebase now. We'll call this shared component loader.vue
and this component will help us display a message or interaction to the user of our app whenever we're loading data from an external source.
Let's create a file called loader.vue
at /resources/assets/js/components/shared
.
<template lang="html">
<div v-show="loading" class="loader text-center">
<section class="loader__bar"></section>
<section class="loader__indicator">
<span class="loader__text">Have a coffee. We'll be done in a bit.</span>
</section>
</div>
</template>
<script>
export default {
props: {
loading: {
type: Boolean,
default() {
return false;
},
}
},
}
</script>
<style>
@keyframes growHorizontal {
0% {
width: 100%;
background: #eee;
opacity: 0.9;
}
100% {
width: 0;
background: #fefefe;
opacity: 0;
}
}
.loader
{
display: flex;
flex-direction: column;
justify-content: center;
background: rgba(0, 160, 218, 0.9);
height: 100%;
width: 100%;
color: #fff;
overflow: hidden;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 9999;
top: 0;
}
.loader__bar
{
position: fixed;
top: 5px;
right: 0;
height: 2px;
width: 100%;
opacity: 0;
background: #fefefe;
animation-name: 'growHorizontal';
animation-duration: 5s;
animation-direction: reverse;
}
.loader__icon,
.loader__text
{
display: block;
}
.loader__icon
{
color: #fcfcfc;
margin-bottom: 20px;
}
.loader__text
{
color: #fefefe;
font-size: 1.5em;
letter-spacing: 1.3px;
}
</style>
We added a barebones HTML structure for this structure in the <template>
tag. We have just three nodes:
Loader bar: This will display a little progressive loading bar at the top.
Loader indicator: This simply contains any indicators to the user that an action is taking place.
Loader text: Displays a message to the user.
Our loader
component expects to be notified about its status when it is being referenced. It does this by expecting a property called loading
to be passed to it at runtime. The loading
property is a boolean value and it defaults to false when no properties are supplied. We carry this out in the <script>
tag. We define some styles for this component in the <style>
tag.
2. Building the Add Personal Component
We defined a <router-view></router-view>
component in our resources/assets/js/components/Index.vue
file last time on the series. The router-view
helps us show different steps in our wizard depending on which route our user is currently on. We then defined a route called add-personal
that maps to a add-personal.vue
component. Let's build out this component now.
<template>
<div class="container">
<div class="row">
<section class="clearfix">
<div class="col-md-6 col-md-offset-3 u-mb-lg">
<form class="panel panel-default" method="post" @submit.prevent="updatePersonal">
<section class="panel-heading">
<h3 class="text-center text-uppercase">
Add Personal Info
</h3>
</section>
<section class="panel-body">
<div class="form-group">
<label for="">Name</label>
<input name="name" class="form-control" v-validate data-vv-rules="required" :autofocus="true" v-model="personal.name" type="text">
<p class="text-danger text-right" v-if="errors.has('name')">{{ errors.first('name') }}</p>
</div>
<div class="form-group">
<label for="">Display name</label>
<input name="display_name" class="form-control" v-validate data-vv-rules="required" :autofocus="true" v-model="personal.name" type="text">
<p class="text-danger text-right" v-if="errors.has('display_name')">{{ errors.first('display_name') }}</p>
</div>
<div class="form-group u-mb-sm">
<label for="">Birth year</label>
<select v-model="personal.birth_year" class="form-control" name="birth_year" id="">
<option v-for="year in 99" selected :value="(year + 1900)"> {{ (year + 1900) }} </option>
</select>
<p class="text-danger" v-if="errors.has('birth_year')">{{ errors.first('birth_year') }}</p>
</div>
<div class="form-group clearfix u-mb-sm">
<button type="submit" class="btn btn-info btn-lg btn-block">
Next Step (1/3)
</button>
</div>
</section>
</form>
</div>
</section>
</div>
</div>
</template>
<script>
import Vue from 'vue';
import VeeValidate from 'vee-validate';
Vue.use(VeeValidate, {
events: 'blur'
});
import { mapGetters } from 'vuex';
import { mapActions } from 'vuex';
import store from '../../vuex/store';
export default {
props: [],
data: function () {
return {
'personal': {
name: '',
display_name: '',
birth_year: ''
},
}
},
computed: {
...mapGetters([
'getCurrentStep'
])
},
mounted() {
this.setStep(1);
},
methods: {
...mapActions([
'setLoader',
'persistPersonal',
'setStep',
]),
updatePersonal: function () {
var self = this;
this.$validator.validateAll().then(result => {
if (result) {
self.setLoader(true);
this.persistPersonal({
callback: function (data) {
self.setLoader(false);
return self.$parent.moveToStep(self.getCurrentStep + 1);
},
data: this.personal
});
return;
}
alert('Correct every field');
});
}
}
}
</script>
As you can see, we have a few form elements in our add-personal
component. This means we also have to make sure users enter valid information. We can do this by using the VeeValidate
Vue plugin and defining a few rules for the name
, display_name
and birth_date
fields: We do this by defining the rule 'required' on them in the data-vv-rules
HTML attribute. We also show any error messages on the fields that are available.
In the birth_date
field, we generate a series of birth years from the year 1910 - 1999 using the v-for
Vue directive.
In our script
tag, we configure the VueValidate plugin. We set it to only run validations when a user leaves a field. We import our mapGetters
and mapActions
modules as we'll be using them to get and set global state values. We also define some defaults for the state specific to this component.
We hook into the mounted
action of this component and we then set our current step to "1".
Next, we write a handler for our form submit action. In this handler, we show our loader, run validations before persisting the data to the server for permanent storage, hide our loader and then increment the currentStep
state property. If we encounter any validation errors, we simply show an error message.
3. Building the Add Team Component.
We'll allow our users to add their favorite team to the application. We'll allow them to either choose from all available teams created by other users or we'll let them create their own teams complete with nicknames and artwork.
Let's add some code to resources/assets/js/components/profile/add-team.vue
<template>
<section>
<team-modal :open="getTeamModalStatus" @teamSelected="addTeam"></team-modal>
<team-create-modal :open="getTeamCreateModalStatus" @teamSaved="processTeam"></team-create-modal>
<competition-modal :open="getCompetitionModalStatus" @competitionSaved="setCompetitionID"></competition-modal>
<div class="container">
<div class="row">
<section class="clearfix">
<div class="col-md-6 col-md-offset-3 form-container u-mb-lg" style="margin-bottom: 30px;">
<form class="panel panel-default" method="post" @submit.prevent="nextStep">
<section class="panel-heading">
<h3 class="u-mb-md text-center">
Add a Team
</h3>
</section>
<section class="panel-body">
<section class="text-center">
<h4 class="text-bold text-uppercase u-mb-sm">Teams Selection </h4>
<a href="#" @click.prevent="triggerTeamModal" class="btn btn-primary u-mb-md">
<i class="fa fa-plus"></i> Choose a team
</a>
<a href="#" @click.prevent="triggerTeamCreateModal" class="btn btn-default u-mb-md">
Create a new team
</a>
<section class="tw-teams" v-if="teams">
<h4 class="text-bold text-uppercase u-mb-sm clearfix">Teams</h4>
<section class="row">
<div v-for="team in teams" style="margin-bottom: 15px;" class="col-lg-6">
<section class="tw-team text-center">
<img :src="team.artwork" alt="" class="tw-team__artwork u-mb-sm">
<p class="tw-team__title text-center clearfix">{{ team.alias }}</p>
</section>
<a @click.prevent="removeTeam(team)" href="#" class="btn btn-danger">
<i class="fa fa-trash"></i> Remove {{ team.alias }}
</a>
</div>
</section>
</section>
</section>
<div class="form-group u-mb-md">
<section>
<label class="" for="">
Competitions
</label>
<a href="#" @click.prevent="triggerCompetitionModal" class="btn btn-link pull-right">Create a new competition <b class="caret"></b></a>
</section>
<select v-validate data-vv-rules="required" v-model="competition_id" name="competition_id" class="form-control clearfix">
<option v-for="competition in getCompetitions" :value="competition.id">{{ competition.name }}</option>
</select>
<p class="text-warning" v-if="errors.has('competition_id')">{{ errors.first('competition_id') }}</p>
</div>
<div class="form-group clearfix">
<button type="submit" class="btn btn-lg btn-block btn-info pull-right">
Next Step (2/3)
</button>
</div>
</section>
</form>
</div>
</section>
</div>
</div>
</section>
</template>
<script>
import Vue from 'vue';
import VeeValidate from 'vee-validate';
Vue.use(VeeValidate, {
events: 'input|blur'
});
import { mapGetters } from 'vuex';
import { mapActions } from 'vuex';
Vue.component('team-modal', require('./dialog/team-modal.vue'));
Vue.component('team-create-modal', require('./dialog/team-create-modal.vue'));
Vue.component('competition-modal', require('./dialog/competition-modal.vue'));
export default {
props: [],
data: function () {
return {
team: {
'id': '',
},
'competitors': []
}
},
mounted () {
var self = this;
this.$parent.setStep(2);
if (!this.getCompetitions.length) {
self.setLoader(true);
this.getAllCompetitions(function () {
self.setLoader(false)
});
}
},
computed: {
...mapGetters([
'getCompetitions',
'getTeams',
'getTeamModalStatus',
'getTeamCreateModalStatus',
'getCompetitionModalStatus',
'getCurrentStep'
]),
competitionModalOpen () { return this.isCompetitionModalOpen}
},
methods: {
...mapActions ([
'setLoader',
'setCompetitionModalStatus',
'setTeamModalStatus',
'setTeamCreateModalStatus',
'getAllCompetitions',
'getAllTeams',
'setTeamID'
]),
triggerCompetitionModal () {
this.setCompetitionModalStatus(true);
},
triggerTeamModal () {
var self = this;
this.setTeamModalStatus(true);
},
triggerTeamCreateModal () {
var self = this;
this.setTeamCreateModalStatus(true);
},
processTeam (team) {
var self = this;
this.addTeam(team);
self.setLoader(true);
},
addTeam (teamID) {
var matchFound = false,
self = this;
this.teams.forEach(function (item) {
if (item.id == teamID) {
matchFound = true;
}
});
if (!matchFound) {
this.teams.push(this.getTeams[teamID]);
}
},
removeTeam (team) {
this.teams = this.teams.filter(function(item) {
return item.id !== team.id
});
},
setCompetitionID (data) {
this.competition_id = data.id;
},
}
}
</script>
Our code above does a few things. First of all, we include the <team-modal></team-modal>
, <team-create-modal>
and <competition-modal>
modal components at the top of the template. This helps our modal dialogs gain precedence in the stacking order above other elements in our DOM. We'll work on these modal dialog components soon.
Next, we define a few buttons to trigger our modals and we also get to setup a form with a submit handler.
In our mounted
lifecycle event handler method, we retrieve all available competitions from our API server and we send them to our Vuex store. We do this by dispatching the FETCH_COMPETITIONS
action. We show a loader that is hidden when we complete the request to the API server.
Our computed
class level properties simple uses the Vuex mapGetters
method to access getters defined in our previous tutorial. These getters are available at resources/assets/js/vuex/modules/home/getters.js
.
We also map a few actions we have an interest in for this component. Triggering a modal is simple—set the isComponentModalOpen
property to true and we should get a modal popping up. Bootstrap CSS helps us style the modal for ease of use. We can set this isComponentModalOpen
by calling the corresponding action method we defined earlier in our actions.js
. file.
We have an interesting addTeam
method that does just what it name says. This method searches through the members in the this.teams
array and then proceeds to add a new team to the array if no duplicates are found. The removeTeam
method is pretty self explanatoryU—we simply filter out any teams that match the removal criteria.
4. Setting Up Our Team Creation Modal Dialog.
Modal dialogs are very ubiquitous. You interact with them everyday if you use smartphones. As UI engineers, we can harness modals to help us deliver a superior user experience for our visitors. We'll setup a few modal dialogs for the following actions:
- Team Creation.
- Team Selection.
- Competition Creation.
- Leaderboard Creation.
Hop into the terminal and issue these commands to create our dialog files:
C:\xampp\htdocs\easywiz\assets\js\components\profile\dialog>touch competition-create.vue-modal competition-modal.vue team-create-modal.vue team-modal.vue
We'll need to create a dialog
directory in resources/assets/js/profile
. This directory will house all our modal dialog components. We must now proceed to building our first modal dialog. We'll add some code to team-modal.vue
available at resources/assets/js/components/profile/dialog
.
<template>
<section>
<form method="post">
<div class="modal fade" :class="{ in: open, visible: open }" v-show="open">
<section class="modal-dialog">
<div class="modal-content">
<section class="modal-header">
<h3 class="text-uppercase text-center">
Choose a Team
<button @click.prevent="closeModal" type="button" class="close btn btn-default" data-dismiss="modal">×</button>
</h3>
</section>
<section class="modal-body">
<section class="u-mb-sm">
<section class="input-group">
<span class="input-group-addon"><i class="fa fa-search"></i> Find a team</span>
<input id="search" class="form-control" v-focus autofocus @focus="switchSearchMode" @keyup="this._logQuery" type="text">
</section>
</section>
<section class="tw-teams clearfix">
<h2 class="text-center u-mb-xs">All Teams</h2>
<div class="container-fluid">
<section class="row" v-if="isSearching">
<h3>Search Results for: <b>{{ this.query }}</b> </h3>
<div v-for="team in filterTeams" style="margin-bottom: 15px;" class="col-sm-4 col-xs-6">
<section class="tw-team text-center" @click.prevent="selectTeam(team)">
<img :src="team.artwork" alt="" class="tw-team__artwork u-mb-sm">
<p class="tw-team__title text-center clearfix">{{ team.alias }}</p>
</section>
</div>
</section>
</div>
</section>
</section>
</div>
</section>
</div>
<div class="modal-backdrop" v-show="open"></div>
</form>
</section>
</template>
<script>
import Vue from 'vue';
import { mapGetters } from 'vuex';
import { mapActions } from 'vuex';
// Register a global custom directive called v-focus
Vue.directive('focus', {
// When the bound element is inserted into the DOM...
inserted: function (el) {
// Focus the element
el.focus();
}
})
export default {
data: () => {
return {
query: '',
isSearching: false
}
},
props: {
open: {
type: Boolean,
default() {
return true;
},
},
},
watch: {
open: () => {
document.getElementById('search').focus();
},
query: () => {
this.filterTeams;
}
},
computed: {
...mapGetters([
'getCompetitionModalStatus',
'getTeams'
]),
filterTeams () {
var results = [];
if (this.query.length) {
for (var team in this.getTeams) {
if (this.getTeams[team].name.toLowerCase().indexOf(this.query.toLowerCase()) > -1) {
results.push(this.getTeams[team]);
}
}
return results;
}
return this.getTeams;
},
},
methods: {
...mapActions ([
'setLoader',
'setTeamModalStatus',
'setRegionModalStatus',
'persistCompetition',
'addCompetitionToStore',
]),
_logQuery (e) {
this.query = e.target.value;
},
_getTeams () {
return this.getTeams;
},
switchSearchMode (e) {
this.isSearching = true;
},
closeModal () {
this.setLoader(false);
this.setTeamModalStatus(false);
},
selectTeam (teamID) {
this.$emit('teamSelected', teamID);
this.closeModal();
},
}
}
</script>
<style scoped>
.modal-backdrop
{
background: rgba(0, 0, 0, 0.8);
}
.visible
{
display: block;
}
</style>
We're carrying out some important things tasks here. We are making sure our modal can be dismissed by clicking the close button. When a user clicks the close button, we set isTeamModalOpen
to false
.
We also allow our users to filter their teams down to help make finding teams easier. We can do this by simply updating the this.query
property whenever the keyup
event is triggered. On every keyup
event we call a method named this._logQuery
.
Also, whenever the user focuses on the search bar, we set the state property isSearching
to true
. This allows us to hide other non-search related functions for a better user experience. We also call another method named filterTeams()
. This method allows us to only show teams matching the query typed in the search box.
Finally, we listen to the click
event on the team and then call the selectTeam
handler method. This method simply emits the teamSelected
method for our parent component to handle. We also pass along the teamID
for proper identification of the selected team.
We'll be stopping at this juncture for now. The concepts examined here are really novel and hence could be described as 'unintuitive' so I'd advice we get a good hang of what's going on before we proceed further in this series.
Conclusion
We've arrived at the end of this installment. In this episode, we set up our individual wizard view components for the add-personal
and the add-team
steps. We also had an introduction to working with modals in Vue.
In the next post, we will complete all our modal dialog components and we will work on the completed.vue
component.
Curriculum
Pt 1: Build a Wizard with Lucid Laravel and Vuex (https://utopian.io/utopian-io/@creatrixity/build-a-wizard-with-lucid-laravel-and-vuex)
Pt 2: Build a Wizard with Lucid Laravel and Vuex (https://utopian.io/utopian-io/@creatrixity/pt-2-build-a-wizard-with-lucid-laravel-and-vuex)
Pt 3: Build a Wizard with Lucid Laravel and Vuex (https://utopian.io/utopian-io/@creatrixity/pt-3-build-a-wizard-with-lucid-laravel-and-vuex)
Pt 4: Build a Wizard with Lucid Laravel and Vuex (https://utopian.io/utopian-io/@creatrixity/pt-4-build-a-wizard-with-lucid-laravel-and-vuex)
Pt 5: Build a Wizard with Lucid Laravel and Vuex (https://utopian.io/utopian-io/@creatrixity/pt-5-build-a-wizard-with-lucid-laravel-and-vuex)
Posted on Utopian.io - Rewarding Open Source Contributors
Great tutorial. I agree with your observations on modals
Downvoting a post can decrease pending rewards and make it less visible. Common reasons:
Submit
Thank you for the contribution It has been approved.
Need help? Write a ticket on https://support.utopian.io.
Chat with us on Discord.
[utopian-moderator]
Downvoting a post can decrease pending rewards and make it less visible. Common reasons:
Submit
Hey @creatrixity! Thank you for the great work you've done!
We're already looking forward to your next contribution!
Fully Decentralized Rewards
We hope you will take the time to share your expertise and knowledge by rating contributions made by others on Utopian.io to help us reward the best contributions together.
Utopian Witness!
Vote for Utopian Witness! We are made of developers, system administrators, entrepreneurs, artists, content creators, thinkers. We embrace every nationality, mindset and belief.
Want to chat? Join us on Discord https://discord.me/utopian-io
Downvoting a post can decrease pending rewards and make it less visible. Common reasons:
Submit