Vue.js in Nette

4 years ago by Ivo Toman  

Integrating Vue.js into Nette is not complicated, and as we will soon see, it can be done in just a few minutes. I will show you how to create your own Vue component for autocomplete search with an example.

If you have an SPA, Nette is likely managing the API with which Vue exchanges information, making both applications practically independent. However, I wanted to use Vue on a traditional website in conjunction with Latte templates in components, which requires some integration.

We will start by getting webpack up and running on our project. By the way, if you don't like the classic webpack configuration as much as I do, you can use Symfony Encore, which is much more familiar to PHP developers, in my opinion. Here is the integration of Encore into Nette that I used.

Vue Component

If webpack is already building assets, it's time to create a Vue SFC (Single File Component).

An SFC is a component (e.g., autocomplete.vue) that can be divided into three parts. One is for the HTML template, the second is for JavaScript, and the third is for CSS styles. Although they belong to a single file, I will describe each part separately for better clarity.

1/3 template

<template>
    <div class="autocomplete">
        <form autocomplete="off"><!--required to disable Google Chrome autofill-->
        <input
                type="text"
                :placeholder="placeholder"
                :name="inputname"
                class="form-control"
                v-model="searchquery"
                @input="autoComplete"
                @keydown.down="onArrowDown"
                @keydown.up="onArrowUp"
                @keydown.enter="onEnter"
        >
        <div class="autocomplete-result" v-show="isOpen">
            <ul class="list-group">
                <li class="list-group-item"
                    v-for="(result, i) in results"
                    :key="result.id"
                    @click="setResult(result)"
                    :class="{ 'is-active': i === arrowCounter }"
                >
                    {{ result[choice] }}
                </li>
            </ul>
        </div>
        </form>
    </div>
</template>

First, we defined the component's template. The interesting part of this code is the usage of : which is shorthand for v-bind, ensuring reactivity by connecting with data and props. If you're curious about the @ part, it's shorthand for v-on, which attaches event listeners to the element and triggers the corresponding method when the event occurs, as shown further.

Remember, Vue must have only one root element.

2/3 javascript

<script>
    import axios from 'axios';

    export default {
        name: "autocomplete",
        props: {
            source: {
                type: String,
                required: true,
            },
            choice: {
                type: String,
                required: true,
            },

            placeholder: String,
            inputname: String
        },
        data: function () {
            return {
                searchquery: '',
                results: [],
                isOpen: false,
                arrowCounter: -1,
            }
        },
        methods: {
            autoComplete() {
                this.$emit('autocomplete-start');
                this.results = [];
                if (this.searchquery.length > 2) {
                    axios.get(this.source, {params: {[this.inputname]: this.searchquery}})
                        .then(response => {
                            this.results = response.data.data;
                            this.isOpen = true;
                        })
                        .catch((error) => {
                            // console.log(error);
                        });
                }
            },
            setResult(item) {
                this.isOpen = false;
                this.searchquery = item[this.choice];
                this.product_id = item.id;
                this.$emit('selected', item);
            },
            onArrowDown() {
                if (this.arrowCounter < this.results.length - 1) {
                    this.arrowCounter = this.arrowCounter + 1;
                }
            },
            onArrowUp() {
                if (this.arrowCounter > 0) {
                    this.arrowCounter = this.arrowCounter - 1;
                }
            },
            onEnter() {
                this.setResult(this.results[this.arrowCounter]);
                this.arrowCounter = -1;
            },
            handleClickOutside(evt) {
                if (!this.$el.contains(evt.target)) {
                    this.isOpen = false;
                    this.arrowCounter = -1;
                }
            }
        },
        mounted() {
            document.addEventListener('click', this.handleClickOutside)
        },
        destroyed() {
            document.removeEventListener('click', this.handleClickOutside)
        }
    }
</script>

First, we imported axios, a library for HTTP communication (ajax). Then, we prepared the props, which are the parameters passed to the component from the outside upon initialization. We also set up the data that are reflected in the template (note: data must be returned as a function to prevent interference between multiple components).

Finally, we defined the methods that are triggered either through events (see v-on) or by calling them in the code. Noteworthy is this.$emit('selected', item);, which triggers the selected event, allowing external handling of this event, similar to concepts used in Nette. This allows the component to be reused in various places with different sources and result processing.

Remember the presenter lifecycle in Nette? Well, Vue has one too. We can use hooks, like the one we used to close the autocomplete when the user clicks outside it.

3/3 css

<style scoped>
    .autocomplete-result .is-active,
    .autocomplete-result li:hover {
        background-color: #379d2f;
        color: white;
    }
</style>

Note that with style scoped, the CSS is only local to the component and won't interfere with other styles.

Connecting the Vue Component with Nette

Great, we have our Vue component. But how do we connect it with Nette?

We create a standard Nette component and call the Vue component in a Latte template. Simple, right?

Simple Example of a Nette Component

class ProductsAutocomplete extends Control
{

    /**
     * @var ProductService
     */
    private $productService;

    public $onSelect = [];


    public function __construct(ProductService $productService)
    {
        $this->productService = $productService;
    }

    public function render()
    {
        $this->template->handleParameter = $this->getParameterId('productId');
        $this->template->setFile(__DIR__ . '/ProductsAutocomplete.latte');
        $this->template->render();
    }

    public function handleSelect($productId)
    {
        $product = $this->productService->get($productId);
        $this->onSelect($product);
    }
}

Since we want to use the autocomplete component in multiple places, we need to provide it with information on where to search. We do this by setting attributes on the element – see props above.

ProductsAutocomplete.latte

<div id="autocomplete">
    <autocomplete
        inputname="nameOrCode"
        source="/api/v1/products"
        placeholder="Find product"
        choice="name"
        ref="autocomplete"
    ></autocomplete>
</div>

When we select an item in the autocomplete, we should process the result in Nette (PHP), for example, via signals Nette components

-<div id="autocomplete">
+<div id="autocomplete" data-handle-link="{link select!}" data-handle-param="{$handleParameter}">
    <autocomplete
        inputname="nameOrCode"
        source="/api/v1/products"
        placeholder="Find product"
        choice="name"
        @selected="onSelect"
        ref="autocomplete"
    ></autocomplete>
</div>

So, we first pass information about the signal address named select and the component parameter name to Vue, so that after selecting from the search, we can call this signal via JavaScript. We then set a listener onSelect to trigger upon item selection (see this.$emit('selected', item); in the component).

Vue Instance

autocomplete.js

import Vue from 'vue';
import axios from 'axios';
import autocomplete from './components/autocomplete';

//for Tracy Debug Bar
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';

var vm = new Vue({
    data: function () {
        return {
            handle: null,
            param: null,
        }
    },
    components: {
        //our component
        autocomplete
    },
    methods: {
        onSelect(item) {
            //is emitted from child component and call nette handleAdd
            axios.get(this.handle + '&' + this.param + '=' + item.id)
                .then( (response) => {
                    if (response.data.snippets) {
                        this.updates(response.data.snippets);
                    }
                })

        },
        updates(snippets) {
            this.$refs.autocomplete.searchquery = '';

            Object.keys(snippets).forEach((id) => {
                const el = document.getElementById(id);
                if (el) {
                    this.updateSnippet(el, snippets[id]);
                }
            });

            //maybe reinit nette.ajax.js for new ajax call
            //$.nette.load();
        },
        updateSnippet(el, content) {
            el.innerHTML = content;
        }
    },
    mounted: function () {
        // `this` points to the vm instance
        this.handle = this.$el.getAttribute('data-handle-link');
        this.param = this.$el.getAttribute('data-handle-param');
    }
}).$mount('#autocomplete');

As seen in the above code, we register the autocomplete component into the Vue instance and upon triggering onSelect, we send a signal to Nette via axios. Since axios doesn't understand Nette's snippets, we need to handle snippet redrawing ourselves. It's also possible to use AJAX libraries for Nette (nette.ajax.js, naja.js), but since all my Vue components use axios, I stuck with it.

If I'm not mistaken, in Vue.js 2 it's not possible to read attributes on the parent element of the instance by default. So, we use a workaround by hooking into mounted – see the lifecycle – and set them into data ourselves.

Webpack

Finally, we process everything with webpack. Here is a piece of my Encore setup:

var Encore = require('@symfony/webpack-encore');
const path = require('path');

Encore
    // directory where compiled assets will be stored
    .setOutputPath('www/assets/admin')
    // public path used by the web server to access the output path
    .setPublicPath('/assets/admin')

    .addEntry('admin', [
        path.resolve(__dirname, 'front_dev/src/vue/autocomplete.js'),
    ])

    .addAliases({
        'nette-forms': path.resolve(__dirname, 'vendor/nette/forms/src/assets/netteForms.js')
    })
    .enableBuildNotifications()
    .enableSingleRuntimeChunk()
    .splitEntryChunks()
    .enableSourceMaps(!Encore.isProduction())
    // enables hashed filenames (e.g. app.abc123.css)
    .enableVersioning(Encore.isProduction())

    .enableSassLoader()
    .enablePostCssLoader()
    .enableVueLoader()

The key setting is .enableVueLoader(), without which webpack would throw an error about not being able to process Vue files. Also, don't forget to specify the path to all instances of Vue components that need processing.

Encore
    .addEntry('admin', [
        path.resolve(__dirname, 'front_dev/src/vue/autocomplete.js'),
        path.resolve(__dirname, 'front_dev/src/vue/xxx.js'),
    ])

Now we have a functional autocomplete that we can use in various situations (searching for products, categories, etc.). Each component can be processed by different signals accordingly. This way, we have not only reusable Nette components but also Vue components.