Vue.js with nette

4 years ago by Ivo Toman translated by Depka  

The Integration of Vue.js into Nette is not very difficult and can be done in a few minutes as we soon find out. In this example, I show you how to create a Vue component for search input with autocompletion.

If you're working on SPA project with Nette, then Nette is probably handling the API side of your project, and Vue is communicating with it. Both applications are pretty much independent of each other. I, however, wanted to use Vue on a classic website in conjunction with Nette latte templates, which are inside Nette components. This approach requires a little work on integration.

We'll start with the integration of webpack. By the way if you're not a fan of typical webpack configuration like me, you can use Symfony Encore which is IMHO much more friendly for PHP developers. You can find the integration of Encore into Nette here.

Vue component

After webpack is set to build our assets, it's time to create Vue SFC (Single File Component)

SFC is a component(i.e. autocomplete.vue) which is possible to divide into 3 parts. First part is dedicated to the HTML template, the second part is for Javascript, and the third one is for CSS styles. Although all three parts belong in the same file, I'll describe them separately for better readability.

<template>
    <div class="autocomplete">
        <form autocomplete="off"><!--required for disable google chrome auto fill-->
        <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>

So firstly, we defined the template for our component in the template section. The only thing that is interesting in this code is probably the part with : which is a shortcut for v-bind that handles the connection between data and props. The part with @ is a shortcut for v-on which binds event listeners on a particular element and runs a particular method when the desired event occurs. More on this later.

Don't forget that Vue component has to 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>

We started here with the import of Axios, which is a library used for HTTP communication (ajax). After the import, we prepared props, which are data passed into the component when the component is initialized and data object, which Vue.js needs for its reactivity.

Lastly, we defined methods that are fired through events (see v-on) or by calling them directly in code. this.$emit('selected', item); is also worth to mention as it emits the selected event which can be processed inside the component itself – this concept is very similar to the one we use in Nette. Now we can use the component on multiple places with different data sources and with different result processing.

Do you still remember the Lifecycle of Presenter in Nette? You do? Great! Vue has it too. Hooks can be used for closing the suggestion list when the user clicks outside of the list by binding it to an event.

3/3 css

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

I only mention here that if you set style scoped in the CSS part of our component, the CSS styles will be applied only for this particular component and will not override our other styles on the website.

Connecting Vue component with Nette

All right, we have our Vue component, but how do we connect it with Nette?

Let's create regular Nette component and dial our Vue component inside it's Latte template. Simple, right?

A simple example of 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);
	}
}

If we want to use our autocomplete component in multiple places, we need to pass it information on where to look for data. We can do that simply by setting attributes on a particular element – see props above.

ProductsAutocomplete.latte

<div id="autocomplete">
    <autocomplete
		inputname="nameOrCode"
		source="/api/v1/products"
		placeholder="Najít produkt"
 		choice="name"
		ref="autocomplete"
	></autocomplete>
</div>

Once we choose the suggested item, we should process the result in Nette (PHP) i.e., by using signals of Nette component

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

First of all, we pass into Vue the address of our signal, which is called select and name of Nette component's parameter, so we can call this signal in javascript after choosing an item from the list. In the next step, we set up onSelect listener which is fired when an item is selected (see this.$emit('selected', item); in the Vue 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 you can see from the above code, we register the autocomplete component into the Vue instance and send the signal to Nette via Axios when onSelect is fired. Axios doesn't know about Nette snippets, so we need to redraw our snippet by ourselves. It's possible to use AJAX libraries for Nette(nette.ajax.js, naja.js), but since all my Vue components use Axios, I stayed with Axios.

If I'm not wrong, it's not possible to read attributes on the parent element in Vue.js 2, so we need to use a little hack with attaching to mounted and set data ourselves – see life cycle.

Webpack

And finally, we can let Webpack process it. Here's a sample of my Encore setting.

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()

Setting .enableVueLoader() is important here, Webpack would throw an error about processing Vue files without it. Also, don't forget to specify the path to all Vue components that we need to process.

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

So now we have our autocomplete component working, and we can start to use it on many different occasions (searching for products, categories, etc.). A different signal can process every component. So not only we have reusable components in Nette but also in Vue :)