How do I get my form submits to set the `phx-submit-loading` class for a minimum amount of time?

I have my forms setup so that .while-submitting shows a little spinning icon on the submit button, to indicate that the submit is happening.

However, I’ve gotten the feedback that the forms submit too fast (!!). I guess that’s a good problem to have, and to me it’s just a testament to how fast websocket communication is. But our users would like to have a little delay on that animation, just to make sure that the form is actually being submitted and we don’t see a flickering button that may indicate a visual bug or something.

If I had total control on the .phx-submit-loading class, I would do something along those lines (in javascript)

const waitFor500ms = new Promise((resolve, reject) => {
  setTimeout(() => resolve(), 500)
})

// let's say this returns a promise that resolves when we get a response from the server
const submit = submitForm()

form.classList.add('phx-submit-loading')
Promise.all([submit, waitFor500ms]).finally(() => form.classList.remove('phx-submit-loading')

This way, my spinning icon would be visible for half a second (or longer if the server actually took longer to respond), clearly indicating to the user that the form was indeed submitted, instead of being a mere flicker and appearing like it’s broken.

You guys know of any way I could be achieving that with LiveView?

give them a nice :timer.sleep(500) :wink:

Yeah, but no :wink:

I really don’t want to add a delay on the server. It just feels wrong when it’s something that can be done purely on the client.

1 Like

Yes, I totally agree, I was just amused by that as a literal implementation of the requirement.
I’d rather sleep(1000 * 60 * 60 * 8) myself now :slight_smile:
I’m sure you’ll receive better answers!

1 Like

So, I almost got it working with the following hook. It’s a bit of a hacky solution, but it would work if my form’s classList was maintained between renders:

    mounted() {
      this.el.addEventListener('submit', () => {
        const loading = new Promise((resolve, reject) => {
          window.addEventListener('phx:page-loading-stop', () => resolve(), { once: true })
        })
          
        window.addEventListener("phx:page-loading-start", () => {
          this.el.classList.add('custom-submitting')

          const wait = new Promise((resolve, reject) => {
            setTimeout(() => resolve(), 500)
          })

          Promise.all([wait, loading]).then(() => this.el.classList.remove('custom-submitting'))

        }, { once: true })
      })
    },

My form element uses this hook, along with a phx-loading-page attribute so that the form’s events trigger the phx:page-loading-* events.

However, when I receive my submit response from the server (which is near instantaneous), my form gets re-rendered and my custom-submitting class is removed under my nose.

I have the opposite problem. because the form is so fast the topbar from the default phoenix LV template is holding it back. I remove the topbar and replace it with a simpler CSS based progress bar to fully enjoy the speed. :slight_smile:

I guess you can use topbar and even tune some parameter to make topbar even slower for the visual cue.

The topbar is the first thing I remove from any new LiveView project :sweat_smile: I just can’t stand it :man_shrugging:

I’ve got a working solution! I’m not in love with it at all, but it gets the job done and I think it’s pretty cool :slight_smile:

Here my final hook implementation, which I’ve named… drum roll… slowForm :sweat_smile: :

{
  mounted() {
    if (!this.el.hasAttribute('phx-page-loading')) {
      console.error('The slowForm hook requires your form to have the phx-loading-page attribute in order to work.')
      return
    }

    this.el.addEventListener('submit', () => this.handleSubmit())
  },
  updated() {
    // That's the key element that my previous implementation was missing!
    // Since the element is re-rendered, I need to set the class again if we're still submitting.
    if (this.submitting) {
      this.el.classList.add('phx-submit-loading')
    }
  },
  handleSubmit() {
    this.setSubmitting() 

    const wait = new Promise((resolve, reject) => {
      setTimeout(() => resolve(), this.el.dataset.minLoadingTime || 500)
    })

    const loading = new Promise((resolve, reject) => {
      window.addEventListener('phx:page-loading-stop', () => resolve(), { once: true })
    })

    Promise.all([wait, loading]).then(() => this.stopSubmitting())
  },
  setSubmitting() {
    this.submitting = true
    this.el.classList.add('phx-submit-loading')
  },
  stopSubmitting() {
    this.submitting = false
    this.el.classList.remove('phx-submit-loading')
  }
}

And there you go. I’m still open to alternative solutions (or perhaps even a native one, implemented directly into Phoenix LiveView :crossed_fingers:) and constructive criticism about this particular approach!

1 Like

As ever, there’s a Rails plugin for that :laughing:

3 Likes

Why use a plugin when we can reintroduce the Turbo Button :slight_smile: ?

2 Likes

There is another way, a little unconventional but could be easier to implement or even more enterprisey. Just remove the submit button and replace it with a regular button with a client side action. The server side got the content of the form from phx-change anyway. When clicking the client side action, the js just freeze the form, make some delay and visual cues etc. Then pushEvent to the server side telling it whatever it received from the last phx-change is the final one and do the shit it was supposed to do.