Monday, March 23, 2020

Vue.js and dialogs

Let's think for a moment about modal dialogs. What is their usage pattern? What are they for, I mean conceptually...

Dialog unveiled

When we create a dialog it is usually to gather some feedback from the user. It might be either a simple Yes / No or some form that the user needs to fill in and return that input after some form of interaction with that dialog.

Unfortunately there is no direct support for this kinds of interactions in Vue.js (nor in any other reactive framework, to be honest). This means that we need to resort to stuff like this:

data() {
  return {
    isConfirmationDialogVisible: false
  }
},
methods: {
  showConfirmationDialog() {
    this.isConfirmationDialogVisible = true
  },
  hideConfirmationDialog() {
    this.isConfirmationDialogVisible = false
  },
  handleConfirm() {
    this.hideConfirmationDialog()
    // the dialog ended with "OK" - perform some action
  },
  handleCancel() {
    this.hideConfirmationDialog()
    // the dialog ended with "Cancel" - do nothing
  }
}

The reason why we're doing all the state mutation nonsense in every place where we want to use a dialog is that the general approach in framework such as Vue.js is to base everything on state and we're completely ignoring the imperative nature of some of the processes. What is even more disturbing is that quite frankly the isConfirmationDialogVisible doesn't really belong with the place of use of the dialog. It should be an internal implementation detail of the dialog itself. But since we don't have implicit support for imperative programming with Vue.js it is sort of necessary to resort to stuff like that. But is it?

API is not just props and events

You might be tempted to think about the API of a component in terms of props that component accepts and events it emits. And even though they form a very important way of communication between parent and childs it is only 2/3rd of the story. Each method you define in the methods block is essentially part of the API of a component.

Suppose we have a dialog component that has the following two methods:

methods: {
  open() { ... },
  close() { ... }
}

Now if we use that dialog component somewhere it is quite easy to call those methods:

<template>
  <MyDialog ref="dialog" />
</template>

<script>
export default {
  mounted() {
    this.$refs.dialog.open()
  },
  beforeDestroy() {
    this.$refs.dialog.close()
  }
}
</script>

This means that we can imperatively steer when the dialog is open and when it closes. This way the state of visibility of that dialog is stored with that dialog and not in every place that uses that dialog which improves the usability quite a bit.

Promises, promises

Knowing that we can actually call methods on components let's move on to the concepts of modal dialogs.

Modal dialogs are dialogs that limit the possibility of user interaction to their content and usually finish with some result of that interaction. A good example is a popup that asks a question to which a user can say Yes or No or prompts the user to enter some data in which case there are usually two outcomes too: either the user entered the required information and approved his/her choice by pressing OK or resigns from proceeding, usually with the user of a Cancel button. It all bears a lot of resemblance to the alert() and confirm(), doesn't it?

The way it is usually handled in other frameworks (the Windows API, GTK just to name a few) is that the call to the framework method is blocking and once the user interaction is done it returns some result. In the browser a blocking code like that would result in everything going sideways. However, and this is where JavaScript really shines, there is a built-in concept of values that will be delivered later in time. This is the concept of Promises.

What if our dialog would expose a function like that:

methods: {
  async show() {
    return new Promise(resolve => {
      this.resolve = resolve
      this.show = true
    })
  },
  onOkButtonClick() {
    this.show = false
    this.resolve && this.resolve('ok')
  },
  onCancelButtonClick() {
    this.show = false
    this.resolve && this.resolve('cancel')
  },
},
data() {
  return {
    show: false,
    resolve: null
  }
}

Now that we have this we can use it in the code of a component that needs this kinds of interaction in a very nice way:

methods: {
  save() {
    const confirmation = await this.$refs.dialog.show()
    if (confirmation === 'ok') {
      // do something, the user is OK with it :)
    }
  }
}

The most important part of that approach is that you're not multiplying state that doesn't need to be multiplied and as a bonus your code expresses the intent: show a modal and react to the result of its interaction with the user

Have fun!

No comments: