A Straightforward Vue.js Typeahead Component

Dyploma

Dyploma is a system for managing containerized applications and services on top of Kubernetes in Outbrain. Dyploma includes the concepts of:

  • artifacts

  • builds

  • deployments

  • services

Dyploma includes Java Spring backend and a Python command-line tool (CLI). The command-line tool operates through API calls to the backend.

The Dyploma Web Application

To facilitate broader adoption of containers within Outbrain, we set up to develop a web application that will have the capabilities of the Dyploma CLI.

The web application will operate by fetching data from the backend and sending operations for execution at the backend. This will be done through the same REST API used by the CLI.

Why Typeahead?

For advanced search, and for filling details in forms, we need a typeahead functionality for:

  • artifacts

  • builds

  • deployments

  • services

  • environments

  • kube clusters

In each case, typeahead is relative to a different field in the data.

Why Another Typeahead Component?

Googling we found many Vue.js typeahead components. But we found them too sophisticated to our needs.

We decided to base our component on Let's build Type Ahead Component with VueJS 2 and Fetch API. However, its usage of transition groups not good for us. And it did not provide for customisation of the typeahead field.

Our Typeahead

Our typeahead is a Vue.js single file component (SFC). We use ES6 (ES2015).

One can customise the field on which it operates. It interacts with your backend through promises.

It gets two props:

  • startAt - how many characters to wait before trying to fetch the list of corresponding options from the server

  • field - a structure describing the typeahead field

The Field

  {

      key: 'serviceId',

      autocomplete: 'name',

      placeholder: 'Select a service',

}

The Result

Emitted with a custom event selected:

this.$emit('selected', { field: this.field.key, value: item.id });

Where:

  • field: the key of the field

  • value: the id of the selected option

The Backend

We have encapsulated the call to the backend to fetch the data into a backend module which has a getAutocomplete obtaining the query and the key of the field and returns a promise.

You will need to write it for your case.

We assume the data contains an id field identifying the selected item.

The Template

The Top Level

<div class="typeahead">

</div>

The Input Box

        <input

            v-model="query"

            @focus="reset"

            type="text"

            class="search-input"

            v-bind:placeholder="field.placeholder"

        >

List of Possible Options

  <ul class="results" v-if="!selected">

         <li

            v-for="item in items"

            :key="item.id"

            v-on:click="selectedItem(item)"

          >

                <span>

                  <strong>{{ item.value  }}</strong>

                </span>

            </li>

   </ul>

The Code

We use Lodash for processing arrays.

import _ from 'lodash';



import backand from `@/backend`;



export default {

  name: 'typeahead',

  props: ['startAt', 'field'],



      data() {

        return {

          items: [],

          query: '',

          selected: false,

        };

      },



      computed: {

        isEmpty() {

          if (!this.query) {

            return false;

          }

          return this.items.length < 1;

        },

      },



  methods: {

        fetchItems() {

          const q = this.query.trim();

          backand.getAutocomplete(q, this.field.key)

                .then((results) => {

                  this.items = this.extractItems(results, this.field);

                });

        },

        extractItems(results, field) {

          return _.concat([{ id: null, value: 'none selected' }], _.map(results, r => ({ id: r.id, value: r[this.field.autocomplete] })));

        },

        reset() {

          this.items = [];

          this.selected = false;

        },

        selectedItem(item) {

          this.selected = true;

          if (item.id) {

            this.query = item.value;

            this.$emit('selected', { field: this.field.key, value: item.id });

          } else {

            this.query = '';

          }

        },

      },



      watch: {

        query(to, from) {

          if (this.query.trim().length >= this.startAt) {

            this.fetchItems();

          }

        },

      },

};

The Styling

Using SASS:

$height: 18.95vh;

.typeahead {

    flex: 2;

    height: $ * 0.2;



    .search-input {

      position: relative;

      width: 100%;

      height: $height * 0.2;





      font-family: AvenirNext;

      font-size: 14px;

      font-weight: bold;

      text-align: left;

      color: #a7aaac;



      margin: 0;

      padding: 0;



      font-size: 1em;

      outline: 0;

      position: relative;

      color: #2e3d42;



    }

    .search-input::placeholder {

      font-family: AvenirNext;

      font-size: 14px;

      font-weight: bold;

      text-align: left;

      color: #2e3d42;

      padding-left: 5%;

    }



    .search-input:hover {

      border: 1px solid #0097ce;

    }



    .search-input:focus {

      border: 1px solid #0097ce;

    }

    .search-input:active {

      border: 1px solid #0097ce;

    }

    .results {

      margin: 0;

      padding: 0;

      text-align: left;

      position: relative;

      opacity: 1;

      z-index: 1000;





      li {



        font-family: AvenirNext;

        font-size: 14px;

        font-weight: bold;

        text-align: left;

        color: #2e3d42;



        background-color: #fdfefe;



        margin: 0;

        padding: 1em;

        list-style: none;

        width: 100%;

      }



    }



  }

The Code

The Gist