Are you sure?

7 years ago
7 min read

For those time where you don't want the user to delete elements by mistake but don't want to bother him/her with a modal neither.

Full code on GitHub

What are we building?

A bit more context.

Usage.

<delete
    :url="/api/resource/1"
    @delete="deleteResource(1)"
></delete>

One component, five states.

The code necessary to display those states based on a status data field is pretty straightforward. We design a message and a close icon (with fontawesome) inside a flex container. We show them and style them conditionally based on the status. We define some computed properties to make our code cleaner and some props to allow parent components to customize the texts. All of this in a single file component that I called Delete.vue.

Note that I am using boostrap 4 and its utility classes. See you in the code!

<template>
    <!-- 
        Flex container: 
        Wrapper that contains and positions the message and the close icon. 
    -->
    <div class="d-flex justify-content-end align-items-center">

        <!-- 
            Message: 
            The text we show the user, its color and whether we show it or not depend on the status. 
            This is calculated inside computed properties below.  
        -->
        <span 
            v-if="showMessage"
            v-text="message"
            class="mr-1"
            :class="messageClass"
        ></span>

        <!-- 
            Close icon: 
            We always show the icon unless the element has been successfully deleted.
            We make it spin when loading.
            We make it red when not idle.
        -->
        <i 
            v-show="status !== 'success'"
            class="fa fa-close" 
            :class="{'fa-spin': status === 'loading', red: status !== 'idle'}"
        ></i>
    </div>
</template>

<script>
export default {
    // Optional props to allow parent components to customize the texts.
    props: {
        confirmText: { type: String, default: "Are you sure?" },
        successText: { type: String, default: "Deleted" },
        errorText: { type: String, default: "Something went wrong!" },
    },
    
    // Keep track of the current status of the component.
    data() {
        return {
            status: 'idle',
        }
    },
    
    computed: {
        // We show a message on states: `confirm`, `success` or `error`.
        showMessage() {
            return ['confirm', 'success', 'error'].includes(this.status);
        },

        // Dynamically retrieve the text from the props.
        message() {
            return this[this.status + 'Text'] || '';
        },

        // The message text is gray on `confirm` state, red otherwise.
        messageClass() {
            return this.status === 'confirm' ? 'text-muted' : 'text-danger';
        }
    }
}
</script>

<style>
    /* Some styling for the close icon */
    .fa { cursor: pointer; color: #aaa; }
    .fa:hover { color: #555; }
    .fa.red { color: #CC0044; }
    .fa.red:hover { color: #AA0022; }
</style>

Interactions between states

Now that we have a design that dynamically change when the status updates, we need to define how we want those states to interact together.

Status diagram

Initial state

Initial state is idle. But you know that.

data() {
    return {
        status: 'idle',
    }
},

Clicks

Clicking on the close icon brings us to the confirm state and clicking again in the loading state. Also we can start again if we had an error.

Status diagram - clicks

<!-- Close icon: New event listener on click. -->
<i 
    v-show="status !== 'success'"
    class="fa fa-close" 
    :class="{'fa-spin': status === 'loading', red: status !== 'idle'}"
    @click="click"
></i>
methods: {
    click() {
        switch (this.status) {
            case 'idle':
            case 'error':
                this.status = 'confirm';
                break;
            case 'confirm':
                this.status = 'loading';
                this.submit();
                break;
        }
    },
}

Submitting

As you can see in the click() method above, when we reach the the loading state we also submit our request to delete the item. We will accept the delete endpoint as an url prop. If no url is given, we assume we are deleting an item which as not been persisted (yet) and therefore we simply succeed.

Status diagram - submitting

props: {
    // ...
    url: String,
},

methods: {
    // ...
    submit() {
        if (! this.url) {
            return this.onSuccess();
        }

        axios.delete(this.url)
            .then(this.onSuccess)
            .catch(this.onError);
    },
}

The onSuccess and onError methods update the status of the component accordingly. However when we succeed, we also need to notify the parent component by emitting a delete event. At this point, we wait half a second before emitting the event otherwise the element will disappear before we have time to show our success state to the user. Finally, we make this deleteDelay configurable by accepting it as a prop.

props: {
    // ...
    deleteDelay: { type: Number, default: 500 },
},

methods: {
    // ...
    onSuccess() {
        this.status = 'success';
        setTimeout(() => {
            this.$emit('delete');
        }, this.deleteDelay);
    },

    onError() {
        this.status = 'error';
    },
}

Timeouts

The last thing we haven't talked about in the state diagram is the status timeout. Namely, If we wait too long then a confirm status or an error status will go back to an idle status.

Status diagram - timeouts

A naive approach would be:

setTimeout(() => { this.status = 'idle'; }, 3000);

The problem with that is that if you happen to manipulate the component within those 3 seconds, it will go back to idle no matter what you were doing. This can become a real mess when you have several of those timing out. The trick here is to check, when we timeout, if the current status is the same as the initial status. That is, if we were on confirm when we set the timeout and we are still on confirm afterwards, we can be fairly confident that we can go back to idle.

methods: {
    // ...
    timeout(initialStatus) {
        setTimeout(() => {
            if (this.status === initialStatus) {
                this.status = 'idle';
            }
        }, 3000);
    }
}

We can now use this method when we reach the confirm and error states.

methods: {
    // ...
    click() {
        switch (this.status) {
            case 'idle':
            case 'error':
                this.status = 'confirm';
                this.timeout('confirm'); // Here...
                break;
            case 'confirm':
                this.status = 'loading';
                this.submit();
                break;
        }
    },
    onError() {
        this.status = 'error';
        this.timeout('error'); // ... and here.
    },
}

And that's it. Our Delete component is fully functional. It's now time to do some polishing.

Polishing: Transition

In the video, the one on top is what we have and the one on the bottom is what we want. We want transitions. VueJS makes that really easy for us.

<!-- Message: It is now wrapped in a vue transition we called "fade" -->
<transition name="fade">
    <span 
        v-if="showMessage"
        v-text="message"
        class="mr-1"
        :class="messageClass"
    ></span>
</transition>
/* Fade transition. */
.fade-enter-active, .fade-leave-active {
    transition: opacity .5s;
}
.fade-enter, .fade-leave-to {
    opacity: 0;
}

Polishing: Slowing our request

One last thing. Notice that in most cases the request will succeed or fail very quickly. So quickly that the user won't see our loading state long enough to understand it. It just looks a bit glitchy.

To fix this we will wrap our axios promise in a Promise.all that contains a timer so that, no matter what, we execute for at least 300 milliseconds.

Axios-race-condition

Here is our new submit() method. Note that if no url prop is given, then our real promise is null and therefore the timer will be the only promise used which is the expected behavior.

Promise.all([

    // Real promise.
    this.url ? axios.delete(this.url) : null,
    
    // Promise ensuring at least 300ms of execution.
    new Promise((resolve, _) => setTimeout(resolve, 300))
])

.then(this.onSuccess).catch(this.onError);

The problem is that this implementation doesn't work when the request is failing. This is due to the fail-fast ability of Promise.all. Basically, Promise.all waits until all given promises have resolved to resolve but as soon as one of them fails, it will fail instantly.

Axios-race-condition---fail-fast

There doesn't seem to be any glamorous way of fixing this issue. The approach will we take is to ensure the request promise never fails but returns an Error object instead. Then instead of defining a catch callback, we check whether the request promise resolved in an error.

Promise.all([

    // Real promise.
    this.url ? axios.delete(this.url).catch(_ => new Error) : null,

    // Promise ensuring at least 300ms of execution. 
    new Promise((resolve, _) => setTimeout(resolve, 300))
])

// Succeeds or fails based on axios's return value only.
.then(([value]) => value instanceof Error ? this.onError() : this.onSuccess());

Conclusion

I often find myself trying new ways to improve both user experience and my own experience as a developer. This blog will act as some kind of laboratory notebook. Thus, if you also find that interesting feel free to share your thoughts and contribute to the lab.

Back to our delete component, I think it provides a nice starting point that I could leverage in my applications. I am not trying to create a application-agnostic component that will be pulled out of a package. I have tried that enough to realize my apps will always try to hack it in some ways. What I'm trying to do here is to create some goodies that I can copy/paste/modify into my projects. That's my sweet spot.

Full code on GitHub

Discussions

Would you like to chime in?

You must be a member to start a new discussion.

Fortunately, it only takes two click to become one. See you on the other side! 🌸

Become a Member