10

I want to implement this functionality in vuejs app using bootstrap vue modal component:

When the user clicks on the Delete button on the page UI :

  • It shows the modal with a dynamic content in its body: "Are you sure you want to delete customer: customer_name_here"

  • If the user clicks on the 'Cancel' button: The modal goes away.

  • If the user clicks on the 'OK' button:

  • It changes the modal body content to: 'Deleting customer 'customer_name_here' ... , it disables the Cancel and OK buttons and it calls the API to delete the customer.

When successful response is received from the API:

  • It changes the modal body content to: 'Successfully deleted customer 'customer_name_here'
  • Display only the OK button in the modal footer, which if clicked modal goes away.

This the code so far:

 <b-button   v-b-modal.modal1  variant="danger">Delete</b-button>

    <b-modal id="modal1" title="Delete Customer" 
@ok="deleteCustomer" centered no-close-on-backdrop -close-on-esc ref="modal">
        <p class="my-4">Are you sure, you want to delete customer:</p>
        <p>{{customer.name}}</p>
      </b-modal>

Vue JS code:

deleteCustomer(evt) {

      evt.preventDefault()
      this.$refs.modal.hide()

      CustomerApi.deleteCustomer(this.customer.id).then(response => {
          // successful response
        })
Cœur
  • 32,421
  • 21
  • 173
  • 232
ace
  • 12,531
  • 35
  • 100
  • 167
  • 1
    so what is the problem you met? it looks uses `v-if`/`v-show` will reach the goal. like if delete, show the warning message and OK/Cancel buttons, then hide delete button – Sphinx Aug 24 '18 at 17:37

4 Answers4

10

If I understand correctly, you'd like to display the Modal content based on different state combinations.

As your descriptions, there should be 2 state:

  1. deletingState: it indicates whether begin deleting

  2. loadingState: it indicates whether is waiting the response from the server

Check Bootstrap Vue Modal Guide, then search keyword= Disabling built-in buttons, you will see we can use cancel-disabled and ok-disabled props to control the disable state of default Cancel and OK buttons (or you can use the slot=modal-footer, or modal-ok, modal-cancel.).

Other props you may use: ok-only, cancel-only, busy.

Finally bind v-if and props with the state combinations to show the content.

Like below demo:

Vue.config.productionTip = false
new Vue({
  el: '#app',
  data() {
    return {
      customer: {name: 'demo'},
      deletingState: false, // init=false, if pop up modal, change it to true
      loadingState: false // when waiting for server respond, it will be true, otherwise, false
    }
  },
  methods: {
    deleteCustomer: function() {
     this.deletingState = false
      this.loadingState = false
      this.$refs.myModalRef.show()
    },
    proceedReq: function (bvEvt) {
     if(!this.deletingState) {
        bvEvt.preventDefault() //if deletingState is false, doesn't close the modal
        this.deletingState = true
        this.loadingState = true
        setTimeout(()=>{
          console.log('simulate to wait for server respond...')
          this.loadingState = false
          this.deletingState = true
        }, 1500)
      } else {
       console.log('confirm to delete...')
      }
    },
    cancelReq: function () {
     console.log('cancelled')
    }
  }
})
.customer-name {
  background-color:green;
  font-weight:bold;
}
<!-- Add this to <head> -->
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap/dist/css/bootstrap.min.css" />
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue.css" />

<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.16/vue.js"></script>
<!-- Add this after vue.js -->
<script src="//unpkg.com/babel-polyfill@latest/dist/polyfill.min.js"></script>
<script src="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue.js"></script>

<div id="app">
  <b-button v-b-modal.modal1 variant="danger" @click="deleteCustomer()">Delete</b-button>

  <b-modal title="Delete Customer" centered no-close-on-backdrop no-close-on-esc ref="myModalRef"
  @ok="proceedReq($event)" @cancel="cancelReq()" :cancel-disabled="deletingState" :ok-disabled="loadingState" :ok-only="deletingState && !loadingState">
    <div v-if="!deletingState">
      <p class="my-4">Are you sure, you want to delete customer:<span class="customer-name">{{customer.name}}</span></p>
    </div>
    <div v-else>
      <p v-if="loadingState">
        Deleting customer <span class="customer-name">{{customer.name}}</span>
      </p>
      <p v-else>
        Successfully deleted customer <span class="customer-name">{{customer.name}}</span>
      </p>
    </div>
    
  </b-modal>
</div>
Sphinx
  • 9,326
  • 2
  • 21
  • 40
  • Sphinx thank u great answer works very well but I could not figure out once deletingState is set to true who sets it back to false. – ace Aug 28 '18 at 07:34
  • 1
    @ace many choices. **1.** Always set to false when pop up the modal (as above demo does), **2.** set to false when click 'Cancel' button or click OK second time. **3.** listen **hide** event, if hide, set the state to false – Sphinx Aug 28 '18 at 16:28
2

You might prefer to use separate modals, the logic becomes a bit clearer and you can easily add more pathways, for example retry on API error.

console.clear()
const CustomerApi = {
  deleteCustomer: (id) => {
    return new Promise((resolve,reject) => {
      setTimeout(() => { 
        if (id !== 1) {
          reject(new Error('Delete has failed'))
        } else {
          resolve('Deleted')
        }
      }, 3000);
    });
  }
}
  
new Vue({
  el: '#app',
  data() {
    return {
      customer: {id: 1, name: 'myCustomer'},
      id: 1,
      error: null
    }
  },
  methods: {
    deleteCustomer(e) {
      e.preventDefault()

      this.$refs.modalDeleting.show()
      this.$refs.modalDelete.hide()

      CustomerApi.deleteCustomer(this.id)
        .then(response => {
          this.$refs.modalDeleting.hide()
          this.$refs.modalDeleted.show()
        })
        .catch(error => {
          this.error = error.message
          this.id = 1  // For demo, api success 2nd try
          this.$refs.modalError.show()
        })
    }
  }
})
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap/dist/css/bootstrap.min.css" />
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue.css" />

<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.16/vue.js"></script>
<script src="//unpkg.com/babel-polyfill@latest/dist/polyfill.min.js"></script>
<script src="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue.js"></script>

<div id="app">
<b-button v-b-modal.modal-delete variant="danger">Delete</b-button>

<input type="test" id="custId" v-model="id">
<label for="custId">Enter 2 to make it fail</label>

<b-modal 
  id="modal-delete" 
  ref="modalDelete"
  title="Delete Customer" 
  @ok="deleteCustomer" 
  centered no-close-on-backdrop close-on-esc>
  <p class="my-4">Are you sure, you want to delete customer: {{customer.name}}</p>
</b-modal>

<b-modal 
  ref="modalDeleting"
  title="Deleting Customer" 
  centered no-close-on-backdrop no-close-on-esc
  no-fade
  :busy="true">
  <p class="my-4">Deleting customer: {{customer.name}}</p>
</b-modal>

<b-modal 
  ref="modalDeleted"
  title="Customer Deleted" 
  centered no-close-on-backdrop close-on-esc
  no-fade
 :ok-only="true">
  <p class="my-4">Customer '{{customer.name}}' has been deleted</p>
</b-modal>

<b-modal 
  ref="modalError"
  title="Error Deleting Customer" 
  centered no-close-on-backdrop close-on-esc 
  no-fade
  :ok-title="'Retry'"
  @ok="deleteCustomer"> 
  <p class="my-4">An error occured deleting customer: {{customer.name}}</p>
  <p>Error message: {{error}}</p>
</b-modal>

</div>
Richard Matsen
  • 17,100
  • 3
  • 26
  • 59
  • Richard thank you for yet another clever solution. Next adventure I am trying to figure out how to make modal content dynamic which means these modals can be reused to delete other type of object for example photo in which case the text willl be Are you sure you want to delete this photo , deleting photo, photo has been deleted. – ace Aug 30 '18 at 09:20
  • Cheers. The idea of a generic modal crossed my mind, but could not see a concrete pattern. Perhaps a functional component with render function, ref [Vue NYC - VueJS Render Functions / Functional Components - Dan Aprahamian](https://www.youtube.com/watch?v=KS4eizPXRCQ) and [daprahamian/vue-render-functions-example](https://github.com/daprahamian/vue-render-functions-example) – Richard Matsen Aug 30 '18 at 09:25
  • One idea is to define three data variables initialDeleteText, deletingText, deletedText with default value for customer but for photo object the values will change – ace Aug 30 '18 at 09:39
  • Indeed, the textual bits aren't too much of a problem, you can deal with them by interpolation the same way as `{{ customer.name }}`. – Richard Matsen Aug 30 '18 at 10:06
  • I'm thinking of a wrapper component that takes an array of states. As Sphinx points out, it's about states but he only has two states when the generic version should take a list with at least 3, the 4th (error) being optional in case it isn't needed. The states should be objects with properties for the various texts to be displayed, which buttons are visible, functions the buttons should call, and next state after each button click. All declarative like the basic `b-modal` API. I'll post an example. – Richard Matsen Aug 30 '18 at 10:16
  • @ace probably you'd like to implement something like [Quasar Stepper](https://quasar-framework.org/components/stepper.html) – Sphinx Aug 30 '18 at 17:59
2

Here is a generic wrapper component for Bootstrap-vue modal that takes an array of states and navigates according to the nextState property. It makes use of computed properties to respond to the state changes.

In the parent, the array of states is also defined in a computed property so that we can add customer (or photo) properties to the messages.

Edit

Added content slots which allow the parent component to define the exact markup inside the modal content.

console.clear()

// Mock CustomerApi
const CustomerApi = {
  deleteCustomer: (id) => {
    console.log('id', id)
    return new Promise((resolve,reject) => {
      setTimeout(() => { 
        if (id !== 1) {
          reject(new Error('Delete has failed'))
        } else {
          resolve('Deleted')
        }
      }, 3000);
    });
  }
}

// Wrapper component to handle state changes
Vue.component('state-based-modal', {
  template: `
    <b-modal 
      ref="innerModal"
      :title="title"
      :ok-disabled="okDisabled"
      :cancel-disabled="cancelDisabled"
      :busy="busy"
      @ok="handleOk"
      :ok-title="okTitle"
      @hidden="hidden"
      v-bind="otherAttributes"
      >
      <div class="content flex-grow" :style="{height: height}">

        <!-- named slot applies to current state -->
        <slot :name="currentState.id + 'State'" v-bind="currentState">
          <!-- default content if no slot provided on parent -->
          <p>{{message}}</p>
        </slot>

      </div>
    </b-modal>`,
  props: ['states', 'open'],  
  data: function () {
    return {
      current: 0,
      error: null
    }
  },
  methods: {
    handleOk(evt) {
      evt.preventDefault();
      // save currentState so we can switch display immediately
      const state = {...this.currentState}; 
      this.displayNextState(true);
      if (state.okButtonHandler) {
        state.okButtonHandler()
          .then(response => {
            this.error = null;
            this.displayNextState(true);
          })
          .catch(error => {
            this.error = error.message;
            this.displayNextState(false);
          })
      }
    },
    displayNextState(success) {
      const nextState = this.getNextState(success);
      if (nextState == -1) {
        this.$refs.innerModal.hide();
        this.hidden();
      } else {
        this.current = nextState;
      }
    },
    getNextState(success) {
      // nextState can be 
      //  - a string = always go to this state
      //  - an object with success or fail pathways
      const nextState = typeof this.currentState.nextState === 'string'
        ? this.currentState.nextState
        : success && this.currentState.nextState.onSuccess
          ? this.currentState.nextState.onSuccess
          : !success && this.currentState.nextState.onError
            ? this.currentState.nextState.onError
            : undefined;
      return this.states.findIndex(state => state.id === nextState);
    },
    hidden() {
      this.current = 0;     // Reset to initial state
      this.$emit('hidden'); // Inform parent component
    }
  },
  computed: {
    currentState() {
      const currentState = this.current;
      return this.states[currentState];
    },
    title() { 
      return this.currentState.title; 
    },
    message() {
      return this.currentState.message; 
    },
    okDisabled() {
      return !!this.currentState.okDisabled;
    },
    cancelDisabled() {
      return !!this.currentState.cancelDisabled;
    },
    busy() {
      return !!this.currentState.busy;
    },
    okTitle() {
      return this.currentState.okTitle;
    },
    otherAttributes() {
      const otherAttributes = this.currentState.otherAttributes || [];
      return otherAttributes
        .reduce((obj, v) => { obj[v] = null; return obj; }, {})
    },
  },
  watch: {
    open: function(value) {
      if (value) {
        this.$refs.innerModal.show();
      } 
    }
  }
})

// Parent component
new Vue({
  el: '#app',
  data() {
    return {
      customer: {id: 1, name: 'myCustomer'},
      idToDelete: 1,
      openModal: false
    }
  },
  methods: {
    deleteCustomer(id) {
      // Return the Promise and let wrapper component handle result/error
      return CustomerApi.deleteCustomer(id)  
    },
    modalIsHidden(event) {
      this.openModal = false;  // Reset to start condition
    }
  },
  computed: {
    avatar() {
      return `https://robohash.org/${this.customer.name}?set=set4`
    },
    modalStates() {
      return [
        { 
          id: 'delete', 
          title: 'Delete Customer', 
          message: `delete customer: ${this.customer.name}`,
          okButtonHandler: () => this.deleteCustomer(this.idToDelete),
          nextState: 'deleting',
          otherAttributes: ['centered no-close-on-backdrop close-on-esc']
        },
        { 
          id: 'deleting', 
          title: 'Deleting Customer',
          message: `Deleting customer: ${this.customer.name}`,
          okDisabled: true,
          cancelDisabled: true,
          nextState: { onSuccess: 'deleted', onError: 'error' },
          otherAttributes: ['no-close-on-esc'],
          contentHeight: '250px'
        },
        { 
          id: 'deleted', 
          title: 'Customer Deleted', 
          message: `Deleting customer: ${this.customer.name}`,
          cancelDisabled: true,
          nextState: '',
          otherAttributes: ['close-on-esc']
        },
        { 
          id: 'error', 
          title: 'Error Deleting Customer', 
          message: `Error deleting customer: ${this.customer.name}`,
          okTitle: 'Retry',
          okButtonHandler: () => this.deleteCustomer(1),
          nextState: 'deleting',
          otherAttributes: ['close-on-esc']
        },
      ];
    }
  }
})
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap/dist/css/bootstrap.min.css" />
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue.css" />

<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.16/vue.js"></script>
<script src="//unpkg.com/babel-polyfill@latest/dist/polyfill.min.js"></script>
<script src="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue.js"></script>

<div id="app">
<b-button @click="openModal = true" variant="danger">Delete</b-button>

<input type="test" id="custId" v-model="idToDelete">
<label for="custId">Enter 2 to make it fail</label>

<state-based-modal 
  :states="modalStates" 
  :open="openModal"
  @hidden="modalIsHidden"
  >
  <template slot="deleteState" scope="state">
    <img alt="Mindy" :src="avatar" style="width: 150px">
    <p>DO YOU REALLY WANT TO {{state.message}}</p>
  </template>
  <template slot="errorState" scope="state">
    <p>Error message: {{state.error}}</p>
  </template>
</state-based-modal> 

</div>
Richard Matsen
  • 17,100
  • 3
  • 26
  • 59
  • 1
    for generic purpose, i think use slott&scoped-slot will be better like Quasar Stepper implemented. – Sphinx Aug 31 '18 at 04:31
  • 1
    A stepper is not a bad idea in principle, but take a look at the examples - the code is as long as an content-injected modal. You would want to loose the step indicator and the buttons should be in a fixed footer position, not changing position with the content height. And would need branching logic not just linear steps. – Richard Matsen Aug 31 '18 at 10:49
  • 1
    this is [one rough demo](https://jsfiddle.net/t4dg5s6x/), my idea, uses template control the content, uses the `step-begin` and `step-end` etc to control the modal itself or skip step etc. – Sphinx Aug 31 '18 at 17:34
  • 1
    Cheers, thanks for that, works well. A tad longer than the wrapped component, but perhaps I'm just more familiar with that pattern. When you mentioned a stepper I was thinking of dropping in a stepper component into the b-modal in to save coding the state transition logic directly. – Richard Matsen Aug 31 '18 at 19:07
  • It is still a big advantage to declaratively define the states and pathway. I think there is more flexibility doing that in an object rather than html (`` tags). – Richard Matsen Aug 31 '18 at 19:14
  • I think that is a choice between left hand and right hand. But I prefer module/component oriented. Then one advantage in your demo, how it supports a Complicated layout? If I'd like to put one data table and some other complex components into modal body, it may feel painful. You may have to design another component, then add more properties to pass the data of datatable and else, redesign the template to render datatable etc.(Sorry for my poor English, I felt didn't explain my opinion very well) – Sphinx Aug 31 '18 at 20:27
  • I think I see your point. To add e.g data table to the modal, it would be useful to have a content-slot. Unfortunately b-modal has header, footer, button slots but no content. – Richard Matsen Aug 31 '18 at 21:07
  • At the moment I am thinking about changes to requirements involving state changes (not content), e.g @ace mentions photos, so after customer is deleted perhaps confirm delete of associated photos (yes/no) and handle errors as well. This should (hopefully) be added easily with an array of state objects in the js. – Richard Matsen Aug 31 '18 at 21:12
  • On the other hand, that hypothetical change request may also require displaying photo thumbnails so content is also an important consideration. Perhaps my wrapper component should allow a slot for content (not currently provided by b-modal but added somehow by the wrapper). – Richard Matsen Aug 31 '18 at 21:20
0

As we discussed in the comments, another solution is something like Quasar Stepper.

  1. Design one component as the step (the name is b-step-modal in below demo),

  2. then uses one modal-stepper (the name is b-stepper-modal in below demo) as the parent.

  3. Then you just need to list out your all steps as the children of modal-stepper. If you'd like to disable the button or skip one step etc, you can use the step-hook (below demo provides step-begin and step-end) to implement the goal.

Like below rough demo:

Vue.config.productionTip = false

let bModal = Vue.component('BModal')

Vue.component('b-stepper-modal', {
  provide () {
    return {
      _stepper: this
    }
    },
    extends: bModal,
    render(h) {
    let _self = this
    return h(bModal, {props: _self.$props, ref: '_innerModal', on: {
        ok: function (bvEvt) {
        _self.currentStep++
        if(_self.currentStep < _self.steps.length) {
            bvEvt.preventDefault()
        }
      }
    }}, _self.$slots.default)
  },
  data() {
    return {
        steps: [],
      currentStep: 0
    }
  },
  methods: {
    _registerStep(step) {
        this.steps.push(step)
    },
    show () {
        this.$refs._innerModal.show()
    }
  }
})

Vue.component('b-step-modal', {
    inject: {
    _stepper: {
      default () {
        console.error('step must be child of stepper')
      }
        }
  },
  props: ['stepBegin', 'stepEnd'],
  data () {
    return {
        isActive: false,
      stepSeq: 0
    }
  },
  render(h) {
    return this.isActive ?  h('p', {}, this.$slots.default) : null
  },
  created () {
    this.$watch('_stepper.currentStep', function (newVal, oldVal) {
        if(oldVal) {
        if(typeof this.stepEnd === 'function') this.stepEnd()
      } else {
        if(typeof this.stepBegin === 'function') this.stepBegin()
      }
      this.isActive = (newVal === this.stepSeq)
    })
  },
  mounted () {
    this.stepSeq = this._stepper.steps.length
    this._stepper._registerStep(this)
    this.isActive = this._stepper.currentStep === this.stepSeq
  }
})

new Vue({
  el: '#app',
  data() {
    return {
      customer: {
        name: 'demo'
      },
      deletingState: false, // init=false, if pop up modal, change it to true
      loadingState: false // when waiting for server respond, it will be true, otherwise, false
    }
  },
  methods: {
    deleteCustomer: function() {
      this.deletingState = false
      this.loadingState = false
      this.$refs.myModalRef.show()
    },
    proceedReq: function(bvEvt) {
      if (!this.deletingState) {
        bvEvt.preventDefault() //if deletingState is false, doesn't close the modal
        this.deletingState = true
        this.loadingState = true
        setTimeout(() => {
          console.log('simulate to wait for server respond...')
          this.loadingState = false
          this.deletingState = true
        }, 1500)
      } else {
        console.log('confirm to delete...')
      }
    },
    cancelReq: function() {
      console.log('cancelled')
    },
    testStepBeginHandler: function () {
      this.deletingState = true
      this.loadingState = true
      setTimeout(() => {
        console.log('simulate to wait for server respond...')
        this.loadingState = false
        this.deletingState = true
      }, 1500)
    },
    testStepEndHandler: function () {
            console.log('step from show to hide')
    }
  }
})
<!-- Add this to <head> -->
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap/dist/css/bootstrap.min.css" />
<link type="text/css" rel="stylesheet" href="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue.css" />

<script src="https://cdnjs.cloudflare.com/ajax/libs/vue/2.5.16/vue.js"></script>
<!-- Add this after vue.js -->
<script src="//unpkg.com/babel-polyfill@latest/dist/polyfill.min.js"></script>
<script src="//unpkg.com/bootstrap-vue@latest/dist/bootstrap-vue.js"></script>

<div id="app">
  <b-button v-b-modal.modal1 variant="danger" @click="deleteCustomer()">Delete</b-button>

  <b-stepper-modal title="Delete Customer" centered no-close-on-backdrop no-close-on-esc ref="myModalRef" @ok="proceedReq($event)" @cancel="cancelReq()" :cancel-disabled="deletingState" :ok-disabled="loadingState" :ok-only="deletingState && !loadingState">
  <b-step-modal>
    <div>
      <p class="my-4">Are you sure, you want to delete customer:<span class="customer-name">{{customer.name}}</span></p>
    </div>
    </b-step-modal>
     <b-step-modal :step-begin="testStepBeginHandler" :step-end="testStepEndHandler">
    <div>
      <p v-if="loadingState">
        Deleting customer <span class="customer-name">{{customer.name}}</span>
      </p>
      <p v-else>
        Successfully deleted customer <span class="customer-name">{{customer.name}}</span>
      </p>
    </div>
</b-step-modal>
  </b-stepper-modal>
</div>
Sphinx
  • 9,326
  • 2
  • 21
  • 40