Search code examples
formsvue.jscrud

vue managing form editing state, boilerplate code


I have a decent number of forms in my app, in which the user can choose to edit, revert, or save changes to the object, which are eventually saved to the backend.

Very similar to this: (code found in another question)
https://jsfiddle.net/k5j6zj9t/22/

var app = new Vue({
      el: '#app',
      data: {
        isEditing: false,
        user: {
          firstName: 'John',
          lastName: 'Smith',
        }
      },
      mounted() {
        this.cachedUser = Object.assign({}, this.user);
      },
      methods: {
        save() {
          this.cachedUser = Object.assign({}, this.user);
          this.isEditing = false;
        },
        cancel() {
          this.user = Object.assign({}, this.cachedUser);
          this.isEditing = false;
        }
      }
    })

Since v-model binding immediately changes the underlying object, I have to first create a clone of the object. Also I need to save a data member whether the object is in editing state.
Multiply this code for more forms and fields, and I end up with too much data members and a lot of boilerplate code.

In server frameworks like django, a model is in 'temporary state' until it is saved, so I can edit like this

user.first_name = 'aa' # temporary object in memory
user.save() # saved to the db

My question, is there a model component/pattern for vue to handle this task better?
Something that will hold the model state - i.e isEditing, automatically clone the object for form editing, revert the changes, etc.
So I won't have to write such code for so many objects?


Solution

  • Uses Scoped Slots may meet your requirements.

    My solution:

    1. Create one component with one slot

    2. Then this slot will bind values with clonedValues (if closeMode is false, clondedValues = values)

    3. finally, in parent component, generate your template with the properties of scoped slot, then pass it to the slot.

    Like below demo:

    Vue.component('child', {
      template: `
      <div>
        <div>
          <slot v-bind:values="clonedValues"></slot>
        </div>
        <p>
          <button @click="saveAction(clonedValues)">Save</button>
          <button @click="resetAction()">Reset</button>
        </p>
      </div>`,
      props: {
        'cloneMode': {
          type: Boolean,
          default: true
        },
        'values': {
          type: Object,
          default: () => { return new Object() }
        }, 
        'saveAction': {
          type: Function,
          default: function (newValues) {
            this.$emit('save', newValues)
          }
        }, 
        'resetAction': {
          type: Function,
          default: function () {
            this.syncValues(this.values)
          }
        }
      },
      data() {
        return {
          clonedValues: {}
        }
      },
      created: function () {
        this.syncValues(this.values)
      },
      watch: {
        values: {
          handler: function (newVal) {
            this.syncValues(newVal)
          },
          deep: true
        },
        cloneMode: function () {
          this.syncValues(this.values)
        }
      },
      methods: {
        syncValues: function (newVal) {
          this.clonedValues = this.cloneMode ? Object.assign({}, newVal) : newVal // if you'd like to support nested object, you have to deep clone
        }
      }
    })
    
    Vue.config.productionTip = false
    
    app = new Vue({
      el: "#app",
      data: {
        mode: true,
        labels: ['id', 'name'],
        childForm: {
          'id': 1,
          'name': 'test'
        }
      },
      methods: {
        saveForm: function (ev) {
          Object.keys(this.childForm).forEach((item) => {
            this.childForm[item] = ev[item]
          })
          // call backend to update the data
        },
        changeCurrentValue: function () {
          this.childForm.id += '#'
          this.childForm.name += '@'
        }
      }
    })
    <script src="https://unpkg.com/[email protected]/dist/vue.js"></script>
    <div id="app">
      <p><button @click="mode=!mode">Mode: {{mode}}</button></p>
      <p>Current: {{childForm}}  --<button @click="changeCurrentValue()">Change Current</button></p>
      <child :values="childForm" @save="saveForm($event)" :clone-mode="mode">
        <template slot-scope="slotProps">
          <p>ID: <input v-model="slotProps.values['id']"/></p>
          <p>Name: <input v-model="slotProps.values['name']"/></p>
        </template>
      </child>
    </div>

    Edit for OP requested:

    1. change default slot to named slot=edit, then create one slot=view

    2. added data property=editing, if true, show 'Edit' slot, if false, show 'View' slot.

    3. in parent component, design the template for slot=view.

    Like below demo:

    Vue.component('child', {
      template: `
      <div>
        <div v-show="editing">
          <slot name="edit" v-bind:values="clonedValues"></slot>
          <button @click="saveForm(clonedValues)">Save</button>
          <button @click="resetAction()">Reset</button>
        </div>
        <div v-show="!editing">
          <slot name="view"></slot>
          <button @click="editing = true">Edit</button>
        </div>
      </div>`,
      props: {
        'values': {
          type: Object,
          default: () => { return new Object() }
        }, 
        'saveAction': {
          type: Function,
          default: function (newValues) {
            this.$emit('save', newValues)
          }
        }, 
        'resetAction': {
          type: Function,
          default: function () {
            this.syncValues(this.values)
          }
        }
      },
      data() {
        return {
          editing: false,
          clonedValues: {}
        }
      },
      created: function () {
        this.syncValues(this.values)
      },
      watch: {
        editing: function (newVal) {
          if(newVal) this.syncValues(this.values)
        },
        values: {
          handler: function (newVal) {
            if(this.editing) this.syncValues(newVal) //comment out this if don't want to sync latest props=values
          },
          deep:true
        }
      },
      methods: {
        syncValues: function (newVal) {
          this.clonedValues = Object.assign({}, newVal) // if you'd like to support nested object, you have to deep clone
        },
        saveForm: function (values) {
          this.saveAction(values)
          this.editing = false
        }
      }
    })
    
    Vue.config.productionTip = false
    
    app = new Vue({
      el: "#app",
      data: {
        childForm: {
          'id': 1,
          'name': 'test'
        }
      },
      methods: {
        saveForm: function (ev) {
          Object.keys(this.childForm).forEach((item) => {
            this.childForm[item] = ev[item]
          })
          // call backend to update the data
        },
        changeCurrentValue: function () {
          this.childForm.id += '#'
          this.childForm.name += '@'
        }
      }
    })
    <script src="https://unpkg.com/[email protected]/dist/vue.js"></script>
    <div id="app">
      <p>Current: {{childForm}}  --<button @click="changeCurrentValue()">Change Current</button></p>
      <child :values="childForm" @save="saveForm($event)">
        <template slot-scope="slotProps" slot="edit">
          <h3>---Edit---</h3>
          <p>ID: <input v-model="slotProps.values['id']"/></p>
          <p>Name: <input v-model="slotProps.values['name']"/></p>
        </template>
        <template slot="view">
          <h3>---View---</h3>
          <p>ID: <span>{{childForm['id']}}</span></p>
          <p>Name: <span>{{childForm['name']}}</span></p>
        </template>
      </child>
    </div>