Pt 6: Build a Wizard with Lucid Laravel and Vuex

in #utopian-io7 years ago (edited)

pt-6-banner.png

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?

  1. Working with Shared Components in Vue.
  2. Building the Add Personal Component.
  3. Building the Add Team Component.
  4. 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> &nbsp; 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">&times;</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> &nbsp; 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



Posted on Utopian.io - Rewarding Open Source Contributors

Sort:  

Great tutorial. I agree with your observations on modals

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]

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