Building an Address and Coordinates Field Type Plugin

Contents

In my previous article we built a custom Storyblok field type plugin for entering opening hours. One of the main reasons why we built our own plugin, instead of using a simple text field, was to make it easier for our users to enter data in a standardized form, which in turn allows for consistent formatting of the data in our front-end application.

In the same spirit, today we’ll build a field type plugin for entering address data. Our goal is to make it as easy as possible for our content editors to enter address data for a lot of locations in a uniform manner.

Store finder with list of locations showing addresses.

Setup

If you’re already familiar with how to develop custom Storyblok plugins, you can skip this step.

But if you’re new to the marvelous world of Storyblok plugin development, read on. Storyblok plugins are based on Vue.js components. This makes it possible to build whatever functionality you can imagine. Everything you can do with Vue.js is also possible with a custom Storyblok plugin.

But this also means that although, for very basic plugins, you can use the simple in-browser editor provided by Storyblok itself, usually we need to set up a simple development environment first.

There already is great documentation about how to set up a local development environment for building Storyblok plugins. Please follow the steps described in the article and come back when you’re ready.

Creating a new address plugin in Storyblok.

After setting up a fresh local development environment and starting the development server with npm run dev we’re ready to develope our own plugin.

The starting point

Before we dive deeper into the technical details, let’s start with a clean slate first. Beneath you can see the src/Plugin.vue entry file with the bare minimum code necessary to function as a Storyblok field type plugin. I’ve added comments to highlight the most important parts.

<template>
  <div class="Address">
  </div>
</template>

<script>
export default {
  mixins: [window.Storyblok.plugin],
  watch: {
    model: {
      deep: true,
      handler(value) {
        // Let Storyblok know that the value was updated.
        this.$emit('changed-model', value);
      },
    },
  },
  methods: {
    initWith() {
      return {
        // These are the values which our custom
        // field will return.
        country: '',
        email: '',
        fax: '',
        latitude: null,
        longitude: null,
        phone: '',
        postal_code: '',
        street: '',
        town: '',
        // This is the name of our plugin.
        plugin: 'address',
      };
    },
  },
};
</script>

Most of the values which make up our address field are basic text fields. But we’ll add special formatters later with Cleave.js in order to enforce a certain format for fields like the phone number field for example.

To keep it simple, we’ll use a basic text input for our country field but depending on your needs you could use a select field with a list of all countries, or you could even implement an autocomplete field.

Basic functionality and styling

In the following code snippet you can see the updated <template> section of our plugin. We’ve added the markup for all the fields we want our users to enter for every value of our address plugin.

<template>
  <div class="Address">
    <div class="Address__field">
      <span class="form__topic">
        Street
      </span>
      <input
        v-model="model.street"
        class="uk-form-small uk-width-1-1"
      >
    </div>

    <div class="Address__field">
      <span class="form__topic">
        Town
      </span>
      <input
        v-model="model.town"
        class="uk-form-small uk-width-1-1"
      >
    </div>

    <div class="Address__field">
      <span class="form__topic">
        Postal code
      </span>
      <input
        v-model="model.postal_code"
        class="uk-form-small uk-width-1-1"
      >
    </div>

    <div class="Address__field">
      <span class="form__topic">
        Country
      </span>
      <input
        v-model="model.country"
        class="uk-form-small uk-width-1-1"
      >
    </div>

    <div class="Address__field">
      <span class="form__topic">
        Coordinates
      </span>
      <div class="uk-grid">
        <label class="uk-width-1-2">
          Latitude
          <input
            v-model.number="model.latitude"
            class="uk-form-small uk-width-1-1"
          >
        </label>
        <label class="uk-width-1-2">
          Longitude
          <input
            v-model.number="model.longitude"
            class="uk-form-small uk-width-1-1"
          >
        </label>
      </div>
    </div>

    <div class="Address__field">
      <span class="form__topic">
        Phone
      </span>
      <input
        v-model="model.phone"
        class="uk-form-small uk-width-1-1"
      >
    </div>

    <div class="Address__field">
      <span class="form__topic">
        Fax
      </span>
      <input
        v-model="model.fax"
        class="uk-form-small uk-width-1-1"
      >
    </div>

    <div class="Address__field">
      <span class="form__topic">
        E-Mail
      </span>
      <input
        v-model="model.email"
        type="email"
        class="uk-form-small uk-width-1-1"
      >
    </div>
  </div>
</template>

There are two things to highlight in the code block above. First of all, although the values for latitude and longitude must be numeric, we deliberately don’t use type="numeric" on their <input> fields. The reason for this is that browsers localize the values of numeric input fields. Although this behavior might be preferable in certain situations, in our case we always want the latitude and longitude fields to be numeric fields with a decimal point (instead of a comma in the case our users have configured their browser to use the german language for example).

The second thing you might’ve already noticed is the .number modifier on the v-model directives of the latitude and longitude fields. By using v-model.number we make sure the value which we store in our model object is of type Number as opposed to String.

<style>
.Address__field + .Address__field {
  margin-top: 10px;
}
</style>

Because we can use the UIkit framework (which is the default CSS framework in the Storyblok app) to do the heavy lifting, we only have to add three lines of additional CSS in order to add some space between our address fields.

All necessary fields for displaying an address.

Generate latitude and longitude from the address

So far so good. All the fields which are necessary to display a valid address are ready to be filled with data. If we want to, we could already use our new address plugin. But for now there is nothing we couldn’t have achieved with simply using the regular fields which Storyblok provides us with out of the box. Let’s add our first improvement by making it possible to automatically find the correct latitude and longitude values for the address the user has entered.

         <label class="uk-width-1-2">
           Longitude
           <input
             v-model.number="model.longitude"
             class="uk-form-small uk-width-1-1"
           >
         </label>
       </div>
+      <a
+        class="blok__full-btn uk-margin-small-top"
+        @click="coordinatesByAddress"
+      >
+        <i class="uk-icon-search uk-margin-small-right"/>
+        Generate from address
+      </a>
+      <p
+        v-if="latLngNotFound"
+        class="Address__error"
+      >
+        Could not find coordinates for the given address.
+      </p>
     </div>
 
     <div class="Address__field">

We add a new button in the <template> section of the plugin. Additionally we add a <p> tag to conditionally render an error message if something goes wrong. The @click directive binds a click handler to the <a> tag of the button which triggers the newly created coordinatesByAddress() method which you can see in the following code snippet.

 </template>
 
 <script>
+// The URL of the OpenStreetMap endpoint
+// for fetching location data by address.
+const ENDPOINT = 'https://nominatim.openstreetmap.org/search';
+
 export default {
   mixins: [window.Storyblok.plugin],
+  data() {
+    return {
+      latLngNotFound: false,
+    };
+  },
   watch: {
     model: {
       deep: true,
       handler(value) {
         this.$emit('changed-model', value);
       },
     },
   },
   methods: {
+    async coordinatesByAddress() {
+      try {
+        // Reset the error message before
+        // fetching new location data.
+        this.latLngNotFound = false;
+
+        // Here we build the query string with
+        // all the address data available to us
+        const queryString = [
+          `city=${encodeURI(this.model.town)}`,
+          `country=${encodeURI(this.model.country)}`,
+          `postalcode=${encodeURI(this.model.postal_code)}`,
+          `street=${encodeURI(this.model.street)}`,
+          'format=jsonv2',
+        ].join('&');
+
+        // We use the new `fetch` API to query
+        // the public OpenStreetMap API.
+        const rawResponse = await fetch(`${ENDPOINT}?${queryString}`);
+        const responseJson = await rawResponse.json();
+        const bestMatch = responseJson[0];
+
+        // Throw error if address is not found.
+        if (!bestMatch) throw new Error('Address not found');
+
+        // If OpenStreetMap was able to find us
+        // some coordinates, we update our model.
+        this.model.latitude = parseFloat(bestMatch.lat);
+        this.model.longitude = parseFloat(bestMatch.lon);
+      } catch (error) {
+        this.latLngNotFound = true;
+      }
+    },
     initWith() {
       return {
         country: '',

The coordinatesByAddress() method queries the OpentStreetMap API with the address data which is stored in the model of our plugin. If it can’t find the address or a network error occurs, we set the latLngNotFound variable to true which triggers an error message to be rendered. Otherwise we update the values of our latitude and longitude fields.

We’re using the new Fetch API to make the API request to OpenStreetMap. Keep in mind that this new browser API is not supported in Internet Explorer 11 and older versions of major browsers.

Address fields and automatically filled coordinate fields.

 .Address__field + .Address__field {
   margin-top: 10px;
 }
+
+.Address__error {
+  margin-top: 5px;
+  margin-bottom: 0;
+  color: #d85030;
+}
 </style>

Above you can see some basic styling for the error message which is shown if something goes wrong.

Error message if fetching the address is not possible.

Format fields with Cleave.js

Next up we want our content editors to always enter phone and fax numbers in the same format. Cleave.js is a JavaScript library which makes this very easy to do. In order to use this library we have to install it as a dependency of our plugin first.

npm install cleave.js

After installing Cleave.js we can use it to enforce a certain format for our phone and fax form fields.

         Phone
       </span>
       <input
-        v-model="model.phone"
+        ref="phone"
+        v-model.lazy="model.phone"
         class="uk-form-small uk-width-1-1"
       >
     </div>
         Fax
       </span>
       <input
-        v-model="model.fax"
+        ref="fax"
+        v-model.lazy="model.fax"
         class="uk-form-small uk-width-1-1"
       >
     </div>

In order to make it possible to tell Cleave.js which <input> fields we want to format, we have to add ref properties to both fields. In one of the next code snippet you can see how this makes it possible to reference those elements inside of the JavaScript code of our Vue.js component.

The second thing you’ll notice is that we’ve added a .lazy modifier to the v-model directive. What this does is that it prevents the model value from being updated immediately after the user presses a key, but delays the update until the field loses focus. This is necessary because otherwise Cleave.js can’t reformat the input value on the fly.

 </template>
 
 <script>
+import Cleave from 'cleave.js';
+import 'cleave.js/dist/addons/cleave-phone.at';
+
 // The URL of the OpenStreetMap endpoint
 // for fetching location data by address.
 const ENDPOINT = `https://nominatim.openstreetmap.org/search`;
       },
     },
   },
+  mounted() {
+    const phoneOptions = {
+      phone: true,
+      phoneRegionCode: 'AT',
+    };
+    // A reference to the fields is stored in
+    // the `$refs` array of our component.
+    new Cleave(this.$refs.phone, phoneOptions);
+    new Cleave(this.$refs.fax, phoneOptions);
+  },
   methods: {
     async coordinatesByAddress() {
       try {

In the first of the two diffs above, we import the Cleave.js package and also the cleave-phone addon for the country in which I live (Austria). In the second diff you can see how to use the element references to instantiate Cleave.js.

Auto formatted phone and fax fields.

Using the plugin

After we’re done with coding our plugin, we can build the code by running npm run build. Afterwards we have to copy and paste the generated code from dist/export.js into the editor text field of the Storyblok app and click the publish button.

Our plugin is now added to our Storyblok spaces, which makes it possible to use it in our content types. Let’s edit the schema of the content type for which we want to use opening hours.

Editing the address plugin schema.

Wrapping it up

You can find the full code of the Storyblok Address plugin on GitHub.

If you’ve followed along and you also read my previous article about building an opening hours field type plugin, things might have become a little repetitive. But I assure you, it’s all building up to something greater. In my next article, we’ll create a store finder using both the opening hours plugin from the previous article and the address plugin we’ve built in this article. Stay tuned!


More to read...

About the author

Markus Oberlehner

Markus Oberlehner

Markus Oberlehner is a Open Source Contributor and Blogger living in Austria and a Storyblok Ambassador. He is the creator of avalanche, node-sass-magic-importer, storyblok-migrate.