Building an Opening Hours Field Type Plugin

Contents

Managing a lot of store or office locations on a company website can be a tedious task for content editors. There are many things to consider, like formatting the address data and opening hours of each location in the same way. At first, the problem might seem simple enough to use a plain text field where content editors can enter all the informations regarding a specific location. But rather sooner than later, you come back to the website, only to find out that the address and opening hour informations of some locations are formatted in a different way than others. One possible reason is that various people are responsible for the maintenance of certain locations.

No matter the reason exactly why the formatting got out of hand, in the end we as web developers are responsible for making it as easy as possible for the content editors to enter all the data in a uniform way. The best way to achieve this is to use input fields which are custom tailored to the particular need.

Store finder with list of locations showing opening hours

To solve the problem of formatting opening hours in a uniform way, we’ll build a custom Storyblok field type plugin.

Setup

Thanks to the Storyblok plugin system, which is based on Vue.js components, it’s very easy to build powerful custom field types.

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, before we can get started building our own custom plugin.

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 opening hours plugin in Storyblok

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

Creating the plugin

Although at first you might think there isn’t much complexity involved in building a plugin to simply display the opening hours of a store, there are some pretty complex versions of this out there, built for a variety of content management systems. For example: some of those plugins allow to account for vacations and public holidays in advance.

In order to keep it simple, we’ll build a rather basic implementation. It’s up to you to add further functionalities.

<template>
  <div class="OpeningHours">
  </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 {
        // This array will be filled with the opening
        // hours for every specific day of the week.
        days: [],
        // This is the name of our plugin.
        plugin: 'opening-hours',
      };
    },
  },
};
</script>

Above you can see the initial content of our src/Plugin.vue entry file. Currently we’ve only set up the bare minimum code we need to have a valid Storyblok plugin to work with.

The 7 days of the week

In order to limit the possibility for making mistakes as much as possible, we use separate fields for the opening hours for each day of the week.

<template>
  <div class="OpeningHours">
    <div
      v-for="(day, index) in model.days"
      :key="day.name"
      class="OpeningHours__day"
    >
      <h4 class="OpeningHours__day-name">
        {{ day.name }}
      </h4>
      <ol class="OpeningHours__list uk-margin-top-remove uk-margin-bottom-remove">
        <li
          v-for="(time, timeIndex) in day.times"
          :key="timeIndex"
          class="uk-flex uk-flex-middle"
        >
          <input
            v-model="model.days[index].times[timeIndex].start"
            aria-label="Start time"
            class="uk-form-small uk-width-1-1"
            placeholder="09:00 or 9 AM"
          >
          <span class="OpeningHours__separator">
            -
          </span>
          <input
            v-model="model.days[index].times[timeIndex].end"
            aria-label="End time"
            class="uk-form-small uk-width-1-1"
            placeholder="18:00 or 6 PM"
          >
        </li>
      </ol>
    </div>
  </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 {
        days: [
          {
            name: 'Monday',
            times: [
              {
                start: '',
                end: '',
              },
            ],
          },
          {
            name: 'Tuesday',
            times: [
              {
                start: '',
                end: '',
              },
            ],
          },
          // ...
          // You know how this goes.
          // ...
          {
            name: 'Sunday',
            times: [
              {
                start: '',
                end: '',
              },
            ],
          },
        ],
        // This is the name of our plugin.
        plugin: 'opening-hours',
      };
    },
  },
};
</script>

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

.OpeningHours__day-name {
  margin-bottom: 5px;
}

.OpeningHours__list {
  padding-left: 0;
}

.OpeningHours__separator {
  margin-right: 4px;
  margin-left: 4px;
}
</style>

Above you can see the updated code of our plugin. We’re now rendering a new section for every day of the week. For now each day of the week has two time fields for the time at which the door is opened and the time at which the door is closed. We’ll extend the plugin with the possibility of inserting breaks later.

The data for the v-for loop, which is responsible for rendering the day sections, is coming from the days array of our model.

Sections with time fields for every day of the week

Adding additional time fields

Next we want to add the possibility to split the opening hours into multiple parts in order to make it possible to insert breaks.

         <li
           v-for="(time, timeIndex) in day.times"
           :key="timeIndex"
-          class="uk-flex uk-flex-middle"
+          class="OpeningHours__list-item uk-flex uk-flex-middle"
         >
           <input
             v-model="model.days[index].times[timeIndex].start"
   padding-left: 0;
 }
 
+.OpeningHours__list-item + .OpeningHours__list-item {
+  margin-top: 5px;
+}
+
 .OpeningHours__separator {
   margin-right: 4px;
   margin-left: 4px;

We add a new class OpeningHours__list-item to add some spacing between multiple rows of time fields.

           >
         </li>
       </ol>
+      <a
+        class="blok__full-btn uk-margin-small-top"
+        @click="addFields(index)"
+      >
+        <i class="uk-icon-plus-circle uk-margin-small-right"/>
+        Add fields
+      </a>
     </div>
   </div>
 </template>
     },
   },
   methods: {
+    addFields(index) {
+      this.model.days[index].times.push({
+        start: '',
+        end: '',
+      });
+    },
     initWith() {
       return {
         days: [

Above you can see that we add a new button at the bottom of every day section which triggers the newly added addFields() method.

The addFields() method inserts new fields into the times array of the given day. This automatically triggers the Vue.js plugin to re-render and we can see two new fields added to the day section.

Button for adding new time fields at the bottom of each day section

Removing time fields

Of course, if it’s possible to add new time fields, it also should be possible to remove them.

             class="uk-form-small uk-width-1-1"
             placeholder="18:00 or 6 PM"
           >
+          <a
+            class="assets__item-trash"
+            aria-label="Remove item"
+            @click="removeFields(index, timeIndex)"
+          >
+            <i class="uk-icon-minus-circle"/>
+          </a>
         </li>
       </ol>
       <a
         end: '',
       });
     },
+    removeFields(dayIndex, timeIndex) {
+      this.model.days[dayIndex].times = this.model.days[dayIndex].times
+        .filter((_, i) => i !== timeIndex);
+    },
     initWith() {
       return {
         days: [

In the two code diff blocks above you can see that we’ve added a new icon next to each time field row which triggers a removeFields() method in order to remove the fields from the given day section.

The removeFields() method filters the times array of the given day and removes the given fields.

Icons for removing time fields next to each time field row

Copy previous times

Usually opening hours are quite similar every day except Saturday and / or Sunday. In order to make it even easier for our content editors to enter the same opening hours for the weekdays again and again, we can implement a functionality to copy the opening hours of the previous day to the current day.

     >
       <h4 class="OpeningHours__day-name">
         {{ day.name }}
+        <a
+          v-if="index !== 0"
+          aria-label="Copy from previous day"
+          @click="copyFromPreviousDay(index)"
+        >
+          <i class="uk-icon-copy"/>
+        </a>
       </h4>
       <ol class="OpeningHours__list uk-margin-top-remove uk-margin-bottom-remove">
         <li
       this.model.days[dayIndex].times = this.model.days[dayIndex].times
         .filter((_, i) => i !== timeIndex);
     },
+    copyFromPreviousDay(index) {
+    this.model.days[index].times = this.model.days[index - 1].times.map(x => ({ start: x.start, end: x.end }));
+    },
     initWith() {
       return {
         days: [
 }
 
 .OpeningHours__day-name {
+  display: flex;
   margin-bottom: 5px;
+  justify-content: space-between;
 }
 
 .OpeningHours__list {

We add a new copy icon right to every but the first day headline. Clicking it triggers the newly added copyFromPreviousDay() method which copies the opening hours from the previous day over to the current day.

Using the opening hours plugin

Now that our opening hours plugin is ready, we can build the plugin 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 opening hours plugin schema

After setting up the new field and entering our opening hours, we’re ready to consume the data in our application.

Building an opening hours Vue.js component

The following example plugin is responsible to consume the data which is spit out by our newly created opening hours plugin. It’s trying to do its best to combine multiple days if they have the same opening hours.

<template>
  <div class="AppOpeningHours">
    <table>
      <tbody>
        <tr
          v-for="(row, index) in compactOpeningHours"
          :key="index"
        >
          <td>{{ row.days.join('-') }}</td>
          <td>{{ row.times.join(', ') }}</td>
        </tr>
      </tbody>
    </table>
  </div>
</template>

<script>
export default {
  name: 'AppOpeningHours',
  props: {
    days: {
      default: () => [],
      type: Array,
    },
  },
  computed: {
    compactOpeningHours() {
      return this.days.reduce((prev, current, index) => {
        // If no times are given, skip this day.
        if (!current.times.length) return prev;

        // If at least one day was already added ...
        if (prev.length) {
          const previousTimes = this.days[index - 1].times;

          // ... check if the times of the current day match
          // those of the times of the previously added day.
          if (JSON.stringify(previousTimes) === JSON.stringify(current.times)) {
            // Add the name of the current day as the
            // second day in the array of day names.
            //
            // Example:
            // 1. We start with `['Monday']`.
            // 2. The opening hours of Tuesday do match those
            //    of Monday and we get `['Monday', 'Tuesday']`.
            // 3. If the Wednesday opening hours also match
            //    those of Tuesday we end up with
            //    `['Monday', 'Wednesday']` and so on.
            prev[prev.length - 1].days = [prev[prev.length - 1].days[0], current.name];

            return prev;
          }
        }

        // Add a new row.
        prev.push({
          days: [current.name],
          times: current.times.map(x => `${x.start}-${x.end}`),
        });

        return prev;
      }, []);
    },
  },
};
</script>

Above you can see how you can use the reduce() JavaScript array method to create a compact version of the opening hours we receive from our Storyblok plugin and how to render those compact opening hours in a Vue.js component.

Output of the opening hours Vue.js component

Wrapping it up

Usually I like to keep things simple. If something can be done with a plain text field I’m fine with it. But in certain situations even seemingly simple things can become quite complex. It’s our job as developers to make the complex as straightforward as possible for our users. Entering opening hours for multiple stores is one of those problems which seem to be rather simple at first, but it can become a taunting task for our content editors to keep the formatting in sync between possible dozens of location entries.

By providing a custom tailored input field, we can remove the complexity of correctly formatting the opening hours from the list of things our content editors have to worry about.


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.


More to read...