Saturday, June 16, 2018

Vue.js - editor components

When talking about creating applications in Vue.js it is not hard to find one that does something to data. Obviously for state management there's Vuex, Redux and other stores but in this post we're going to focus strictly on passing state via props to components that one could generally call controls - so more on the lines of custom inputs.

The 3 cases

There are 3 cases that we will encounter when passing data down to controls:

  • We're passing in primitive values (strings, numbers, booleans)
  • We're passing in complex reactive objects (think: list of people)
  • We're passing in a complex reactive object but we'd like to treat it as a primitive and have atomic changes to all its fields

Primitives

In the case of primitives the situation is dead simple: use v-model, you can react to changes by hooking up to the input event - done. The documentation is fantastic in that area so if you'd like to know more about it dig in!

Reactive complex objects

Imagine you have a data structure like this:

data = { firstName: 'John', lastName: 'Doe' }

In this case if you create a control (we'll call it DataEdit.vue) that you'd like to immediately edit the fields you could do something like that:

<template>
  <div>
    <input v-model="value.firstName">
    <input v-model="value.lastName">
  </div>
</template>

<script>
export default {
  props: {
    value: Object
  }
}
</script>

This means that if used in a parent component (ContactForm.vue) like so:

<template>
  <div>
    <h1>{{ person.firstName }} - {{ person.lastName }}</h1>
    <DataEdit :value="person" />
  </div>
</template>

<script>
import DataEdit from './DataEdit.vue'

export default {
  components: {
    DataEdit
  },
  data() {
    return {
      person: { firstName: 'John', lastName: 'Doe' }
    }
  }
}
</script>

then changes to values in inputs in DataEdit.vue component will immediately be reflected in ContactForm.vue. Vue's reactive system at its best.

Treating complex objects like a value

This is the trickiest one because even though we're passing on a complex, reactive object we'd like to get all the changes at once or none at all. You might ask why would you want such a thing? The answer is quite simple: you'd like to implement "OK/Cancel" functionality or (if the edits drive some kind of Ajax requests) limit the number of actions upon edits. It is quite obvious that there will be a need for a copy of the reactive object. For that I use cloneDeep method from Lodash and it works just great so far.

<template>
  <div>
    <input v-model="internal.firstName">
    <input v-model="internal.lastName">
    <button @click="$emit('input', internal)>Save</button>
  </div>
</template>

<script>
import cloneDeep from 'lodash/cloneDeep'

export default {
  props: {
    value: Object
  },
  data () {
    return {
      internal: cloneDeep(this.value)
    }
  }
}
</script>

Now if that component is used in the ContactForm.vue (note the change from :value to v-model)

<template>
  <div>
    <h1>{{ person.firstName }} - {{ person.lastName }}</h1>
    <DataEdit v-model="person" @input="personUpdated" />
  </div>
</template>

<script>
import DataEdit from './DataEdit.vue'

export default {
  components: {
    DataEdit
  },
  data() {
    return {
      person: { firstName: 'John', lastName: 'Doe' }
    }
  },
  methods: {
    personUpdated(newValue) {
      console.log('Person has been updated to: ', newValue)
    }
  }
}
</script>

you won't see any changes to the header until they are saved by clicking the Save button. Pretty neat, right? On top of that you can be notified when the change occurred so if some additional action needs to take place (like updating list of people from an external database) by listening to the input event. That is just pure awesome!

One more thing...

If the editor persists it will now share the internal and value objects which will make it behave like the case where everything is reactive. Not good - let's do something about it

<template>
  <div>
    <input v-model="internal.firstName">
    <input v-model="internal.lastName">
    <button @click="$emit('input', internal)">Save</button>
  </div>
</template>

<script>
import cloneDeep from 'lodash/cloneDeep'

export default {
  props: {
    value: Object
  },
  watch: {
    value: {
      handler (newValue) {
        this.internal = cloneDeep(newValue)
      },
      deep: true
    },
  },
  data () {
    return {
      internal: cloneDeep(this.value)
    }
  }
}
</script>

The introduced watch updates the internal state so that it is again disconnected from the ContactForm.vue. Of course in a situation where the DataEdit.vue component is removed from DOM due to let's say closing a popup then the watch is completely unnecessary. It does however come in handy if there might be a possibility that the data object in question (or some of its parts) can be modified from the parent component. The internal state will be out of sync in such case. This might happen if some of the details come from an Ajax request or a timer. The watch covers both cases so it is basically a universal way for data synchronization on changes from parent component.

Working example

I know this is a lot to take in at once. Therefore I have prepared a test application for you that illustrates all the pieces. You can find it at https://github.com/padcom/vue-editor-components

That's it, folks!

No comments: