content/tutorials/2.projects/build-a-user-feedback-widget-with-vue-js-.md
One of our DevRel initiatives at Directus is constantly improving our documentation. As a small team with finite time and resources, we rely a lot on user feedback to help guide our writing efforts. But we were missing the most important bit there – your feedback.
At the time of this post, the Directus Docs runs on VitePress (which in turn is based on Vue.js and Vite). Vitepress is a nice bit of kit for quickly generating a static documentation site, but sadly there’s no built-in feature for gathering user feedback.
So I decided to build my own so our team could make better decisions on where to spend our precious time and attention.
While this project was built in the context of Vitepress, this post will show you how to do it with Vue generally. Here’s what our finished product will look like.
Before we hop 🐰 in , here’s what you’ll need to follow along:
Knowledge
Tooling
First off, we're going to need a place to store all this valuable feedback we'll be gathering.
Create a docs_feedback collection with the following data model:
docs_feedback
- id (Type: uuid)
- date_created (Type: Timestamp, Interface: Date/Time)
- url (Type: String, Interface: Input)
- rating (Type: Integer, Interface: Slider)
- title (Type: String, Interface: Input)
- comments (Type: Text, Interface: Textarea)
Just as if it were the lone dev on a cross-functional team – we’re going to place a lot of different responsibilities on our hard-working little Vue component.
Create a new file in our components directory named ArticleFeedback.vue . Then copy and paste the following code.
<script setup lang="ts">
</script>
<template>
<div class="wrapper">
<div class="step">
<!-- Step 1. Show Rating Buttons -->
<div>
<p class="desc">How can we improve?</p>
<p class="heading">How helpful was this article?</p>
</div>
</div>
<div class="step">
<!-- Step 2. Ask for Comments -->
</div>
<div class="step">
<!-- Step 3. Show Success Message -->
</div>
</div>
</template>
<style scoped>
</style>
We’ve got three different states (or steps as I’m calling them) we’ll need to build.
Now let’s start adding our logic to control these three steps.
<script setup lang="ts">
import { ref, reactive } from 'vue'; // [!code ++]
const props = defineProps<{ // [!code ++]
title: string; // [!code ++]
url: string // [!code ++]
}>(); // [!code ++]
const feedback = reactive<{ // [!code ++]
id?: string; // [!code ++]
rating?: number; // [!code ++]
comments?: string; // [!code ++]
}>({}); // [!code ++]
const success = ref(false); // [!code ++]
</script>
<template>
<div class="wrapper">
<div class="step"> // [!code --]
<div v-if="!feedback.rating" class="step"> // [!code ++]
<!-- Step 1. Show Rating Buttons -->
<div>
<p class="desc">How can we improve?</p>
<p class="heading">How helpful was this article?</p>
</div>
</div>
<div class="step"> // [!code --]
<div v-else-if="feedback.rating && !success" class="step"> // [!code ++]
<!-- Step 2. Ask for Comments -->
</div>
<div class="step"> // [!code --]
<div v-else class="step"> // [!code ++]
<!-- Step 3. Show Success Message -->
</div>
</div>
</template>
<style scoped>
</style>
ref and reactive functions from Vue.url and page title as props from the parent component that contains this widget.feedback to manage our form submission data.success variable to hold the success state.v-if, v-else-if, and v-else to control what step of the feedback process is shown.With the logic roughed in, let’s add our rating buttons.
<script setup lang="ts">
import { ref, reactive } from 'vue';
const props = defineProps<{ title: string; url: string }>();
const feedback = reactive<{
id?: string;
rating?: number;
comments?: string;
}>({});
const ratingOptions = [ // [!code ++]
{ label: 'Worst Doc Ever 🗑️', value: 1, message: 'Woof! 🤦♂️ Sorry about that. How do we fix it?' }, // [!code ++]
{ label: 'Not Helpful 😡', value: 2, message: '🧐 Help us do better. How can we improve this article?' }, // [!code ++]
{ label: 'Helpful 😃', value: 3, message: 'Nice! 👍 Anything we can improve upon?' }, // [!code ++]
{ label: 'Super Helpful 🤩', value: 4, message: `Awesome! The whole team is rejoicing in celebration! 🥳🎉🎊 Anything you'd like to say to them?` }, // [!code ++]
]; // [!code ++]
function getRatingOption(rating: number) { // [!code ++]
return ratingOptions.find((option) => option.value === rating); // [!code ++]
} // [!code ++]
</script>
<template>
<div class="wrapper">
<div v-if="!feedback.rating" class="step">
<!-- Step 1. Show Rating Buttons -->
<div>
<p class="desc">How can we improve?</p>
<p class="heading">How helpful was this article?</p>
</div>
<div class="button-container"> // [!code ++]
<!-- We'll add a function for handling button clicks while adding our submission logic -->
<button v-for="item in ratingOptions" :key="item.value" class="btn"> // [!code ++]
<span>{{ item.label }}</span> // [!code ++]
</button> // [!code ++]
</div> // [!code ++]
</div>
<div v-else-if="feedback.rating && !success" class="step">
<!-- Step 2. Ask for Comments -->
</div>
<div v-else class="step">
<!-- Step 3. Show Success Message -->
</div>
</div>
</template>
The rating options will be an array of objects that have a visible label, a corresponding value of 1-4, and a dynamicmessage that we’ll display to encourage the user to leave comments after selecting a rating.
We’ll also create a small helper function to return the rating object based when passing a number value. This will come in handy in the second step because we’re going to display the rating the user chose.
Add a new div to Step 1 below the feedback prompt that will contain our rating options. Inside that, we’ll use v-for to loop through the ratingOptions array and render the individual buttons.
<template>
<div class="wrapper">
<div v-if="!feedback.rating" class="step">
<!-- Step 1. Show Rating Buttons -->
<div>
<p class="desc">How can we improve?</p>
<p class="heading">How helpful was this article?</p>
</div>
<div class="button-container">
<button v-for="item in ratingOptions" :key="item.value" class="btn">
<span>{{ item.label }}</span>
</button>
</div>
</div>
<div v-else-if="feedback.rating && !success" class="step">
<!-- Step 2. Ask for Comments -->
<div> // [!code ++]
<p class="desc">This article is</p> // [!code ++]
<div> // [!code ++]
<span>{{ getRatingOption(feedback.rating)?.label }}</span> // [!code ++]
<button class="btn" @click="feedback.rating = undefined"> // [!code ++]
❌ // [!code ++]
</button> // [!code ++]
</div> // [!code ++]
</div> // [!code ++]
<p class="heading">{{ getRatingOption(feedback.rating)?.message }}</p> // [!code ++]
<textarea v-model="feedback.comments" autofocus class="input" /> // [!code ++]
<button class="btn btn-primary" :disabled="!feedback.comments"> // [!code ++]
Send Us Your Feedback // [!code ++]
</button> // [!code ++]
</div>
<div v-else class="step">
<!-- Step 3. Show Success Message -->
<p class="heading">Thanks for your feedback!</p> // [!code ++]
</div>
</div>
</template>
In Step 2 of the process, we’re showing the user the rating they chose using our getRatingOption helper function we created.
To improve the user experience, we’ll also let users go back and choose a different rating in case they picked the wrong one by mistake. Whenever they click the close button we’ll set the feedback.rating property to undefined which will take the user back to Step 1 based on the v-if logic we created.
Below that, we’ll show the proper message for the option they chose to encourage them to leave helpful comments in short form with a textarea input and a submit button.
We’ll also prevent them from submitting from Step 2 when the comments are empty, so we pass the :disabled="!feedback.comments" prop to the button element.
Next, let’s add some basic styling.
// ^^ Rest of ArticleFeedback.vue Component ^^
<style scoped>
.wrapper {
margin: 2rem 0;
padding: 1.5rem;
border: 1px solid rgba(60, 60, 67, .12);
border-radius: 8px;
background: #f6f6f7;
}
.step > * + * {
margin-top: 1rem;
}
.desc {
display: block;
line-height: 20px;
font-size: 12px;
font-weight: 500;
color: rgba(60, 60, 67, .75);
}
.heading {
font-size: 1.2rem;
font-weight: 700;
}
.button-container {
display: grid;
grid-gap: 0.5rem;
}
.btn {
border: 1px solid solid rgba(60, 60, 67, .12);
background-color: #ffffff;
border-radius: 8px;
transition: border-color 0.25s, background-color 0.25s;
display: inline-block;
font-size: 14px;
font-weight: 500;
line-height: 1.5;
margin: 0;
padding: 0.375rem 0.75rem;
text-align: center;
vertical-align: middle;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.5;
}
.btn:hover {
border-color: #6644ff;
}
.btn-primary {
color: #fff;
background-color: #6644ff;
border-color: #6644ff;
}
.btn-primary:hover {
background-color: #4422dd;
border-color: #4422dd;
}
.input {
width: 100%;
height: 100px;
border: 1px solid #ccc;
border-radius: 4px;
padding: 0.375rem 0.75rem;
}
@media screen and (min-width: 768px) {
.button-container {
grid-template-columns: repeat(4, 1fr);
}
}
</style>
We’re going to write a handler function to actually submit our data to our Directus docs_feedback collection.
At the end of our <script> tag, let’s add our submission handler.
async function handleSubmission(rating?: number) {
loading.value = true;
if (rating) feedback.rating = rating;
const body = {
id: feedback.id,
rating: feedback.rating,
comments: feedback.comments,
title: props.title,
url: props.url,
};
// Replace this with your own Directus URL
const directusBaseUrl = 'https://yourdirectusurl.directus.app';
try {
let response;
// If we've already created a feedback record, we'll update it with the new rating or comments.
if (feedback.id) {
response = await fetch(`${directusBaseUrl}/items/docs_feedback/${feedback.id}`, {
method: 'PUT',
body: JSON.stringify(body),
});
} else {
response = await fetch(`${directusBaseUrl}/items/docs_feedback/${feedback.id}`, {
method: 'POST',
body: JSON.stringify(body),
});
}
const data = await response.json();
feedback.id = data.id;
// If the reponse has comments, we can assume they've completed the second step. So we'll show the success message.
if (data.comments) {
success.value = true;
}
} catch (err) {
error.value = err;
} finally {
loading.value = false;
}
}
This function handleSubmission accepts an optional rating and then conditionally creates a new feedback item or updates depending on which step the user completed.
We also need to update our template to call our handler using the @click directive.
<template>
<div class="wrapper">
<Transition name="fade" mode="out-in">
<div v-if="!feedback.rating" class="step">
<div>
<div>
<p class="desc">How can we improve?</p>
<p class="heading">How helpful was this article?</p>
</div>
</div>
<div class="button-container">
<button v-for="item in ratingOptions"
:key="item.value"
class="btn"
@click="handleSubmission(item.value)"> // [!code ++]
<span>{{ item.label }}</span>
</button>
</div>
</div>
<div v-else-if="feedback.rating && !success" class="step">
<div>
<p class="desc">This article is</p>
<div>
<span>{{ getRatingOption(feedback.rating)?.label }}</span>
<button style="margin-left: 0.5rem" class="btn" @click="feedback.rating = undefined">
<span mi icon>close</span>
</button>
</div>
</div>
<p class="heading">{{ getRatingOption(feedback.rating)?.message }}</p>
<textarea v-model="feedback.comments" autofocus class="input" />
<button
class="btn btn-primary"
:disabled="!feedback.comments"
@click="handleSubmission()"> // [!code ++]
Send Us Your Feedback
</button>
</div>
<div v-else class="step">
<p class="heading">Thanks for your feedback!</p>
</div>
</Transition>
</div>
</template>
Sweet! Now there’s just one last step before we have a working component.
Right now, if we try to submit some feedback, we’re probably to going receive an Permission denied error from Directus.
This is because all collections have zero public permissions by default. While this is great for security, it’s not so great if we want to store our feedback data.
Open up the Public role with the Access Control settings. Then scroll to find the docs_feedback collection.
Create and Update Operations
Click the :icon{name="material-symbols:block"} button inside each column and choose :icon{name="material-symbols:check"} All Access.
Read Operation
We might not want any prying eyes to be able to read the actual feedback ratings and content, so we’ll use some custom permissions to restrict the fields that anyone can ‘read’.
Click the button for the Read column, and choose Custom Permissions.
On the Field Permissions tab, check only the id field.
When you’re all done, it should look like this screenshot.
Awesome! Now on to testing.
Let’s open this up our Vue app and our Directus instance to test that everything is working as intended.
Make sure you check that the form submissions are correct inside Directus.
Here’s a few of the next steps you may want to explore beyond this tutorial.
Collecting feedback is just one half of the equation. Analyzing and taking action on the data you receive is the more important part.
Our module for creating no-code dashboards - Directus Insights - can help you understand the data you collect much easier and faster than browsing through a list of feedback.
To post our form submissions, we just enabled Public create and update access for the docs_feedback collection inside Directus.
There’s not a lot to gain by spamming documentation feedback submissions but you never know with folks these days.
Security wise - we could do better.
Here’s a few options:
It could be very handy to know if feedback across different articles is coming from the same user. We don’t really need full blown user sessions stored in the database for this. We could implement it client-side by:
session_id or visitor_id to our collection inside DirectusI hope you find this post useful - if you have any questions feel free to join our community platform.