Better Form Stats (#567)

* Better Form Stats

* fix lint

* submission timer store in localstorage

* Update test case for stats

* remove extra code

* fix form stats

* on restart remove timer

* fix resetTimer function name

* Improve form timer

* Fix timer after form validation error + polish UI

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Chirag Chhatrala
2024-09-18 22:50:52 +05:30
committed by GitHub
parent a057045ef6
commit ceb0648262
14 changed files with 381 additions and 62 deletions

View File

@@ -0,0 +1,57 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { pendingSubmission as pendingSubmissionFun } from "~/composables/forms/pendingSubmission.js"
const props = defineProps({
form: { type: Object, required: true }
})
const pendingSubmission = pendingSubmissionFun(props.form)
const startTime = ref(null)
const completionTime = ref(parseInt(pendingSubmission.getTimer() ?? null))
let timer = null
watch(() => completionTime.value, () => {
if (completionTime.value) {
pendingSubmission.setTimer(completionTime.value)
}
}, { immediate: true })
const startTimer = () => {
if (!startTime.value) {
startTime.value = parseInt(pendingSubmission.getTimer() ?? 1)
completionTime.value = startTime.value
timer = setInterval(() => {
completionTime.value += 1
}, 1000)
}
}
const stopTimer = () => {
if (timer) {
clearInterval(timer)
timer = null
startTime.value = null
}
}
const resetTimer = () => {
stopTimer()
completionTime.value = null
}
onMounted(() => {
document.addEventListener('input', startTimer)
})
onUnmounted(() => {
document.removeEventListener('input', startTimer)
stopTimer()
})
defineExpose({
completionTime,
stopTimer,
resetTimer
})
</script>

View File

@@ -4,6 +4,11 @@
class="open-complete-form"
:style="{ '--font-family': form.font_family }"
>
<FormTimer
ref="formTimer"
:form="form"
/>
<link
v-if="adminPreview && form.font_family"
rel="stylesheet"
@@ -200,6 +205,7 @@
<script>
import OpenForm from './OpenForm.vue'
import OpenFormButton from './OpenFormButton.vue'
import FormTimer from './FormTimer.vue'
import VButton from '~/components/global/VButton.vue'
import FormCleanings from '../../pages/forms/show/FormCleanings.vue'
import VTransition from '~/components/global/transitions/VTransition.vue'
@@ -208,7 +214,7 @@ import clonedeep from "clone-deep"
import ThemeBuilder from "~/lib/forms/themes/ThemeBuilder.js"
export default {
components: { VTransition, VButton, OpenFormButton, OpenForm, FormCleanings },
components: { VTransition, VButton, OpenFormButton, OpenForm, FormCleanings, FormTimer },
props: {
form: { type: Object, required: true },
@@ -274,8 +280,15 @@ export default {
if (form.busy) return
this.loading = true
// Stop the timer and get the completion time
this.$refs.formTimer.stopTimer()
const completionTime = this.$refs.formTimer.completionTime
form.completion_time = completionTime
// this.closeAlert()
form.post('/forms/' + this.form.slug + '/answer').then((data) => {
form.post('/forms/' + this.form.slug + '/answer')
.then((data) => {
useAmplitude().logEvent('form_submission', {
workspace_id: this.form.workspace_id,
form_id: this.form.id
@@ -288,7 +301,8 @@ export default {
id: this.form.id,
redirect_target_url: (this.form.is_pro && data.redirect && data.redirect_url) ? data.redirect_url : null
},
submission_data: form.data()
submission_data: form.data(),
completion_time: completionTime
})
if (this.isIframe) {
@@ -296,6 +310,7 @@ export default {
}
window.postMessage(payload, '*')
this.pendingSubmission.remove()
this.pendingSubmission.removeTimer()
if (data.redirect && data.redirect_url) {
window.location.href = data.redirect_url
@@ -319,12 +334,14 @@ export default {
useAlert().error(error.data.message)
}
this.loading = false
this.$refs.formTimer.startTimer()
onFailure()
})
},
restart () {
this.submitted = false
this.$emit('restarted', true)
this.$refs.formTimer.resetTimer() // Reset the timer
},
passwordEntered () {
if (this.passwordForm.password !== '' && this.passwordForm.password !== null) {

View File

@@ -1,45 +1,60 @@
<template>
<div
class="border border-nt-blue-light bg-blue-50 dark:bg-notion-dark-light rounded-md p-4 mb-5 w-full mx-auto mt-4 select-all"
>
<div
v-if="!form.is_pro"
class="relative"
>
<div class="absolute inset-0 z-10">
<div class="p-5 max-w-md mx-auto mt-5">
<p class="text-center">
You need a <pro-tag
upgrade-modal-title="Upgrade today to access form analytics"
class="mx-1"
/> subscription to access your form
analytics.
</p>
<p class="mt-5 text-center">
<v-button
class="w-full"
@click.prevent="subscriptionModalStore.openModal()"
>
Subscribe
</v-button>
</p>
</div>
</div>
<img
src="/img/pages/forms/blurred_graph.png"
alt="Sample Graph"
class="mx-auto filter blur-md z-0"
>
<div>
<div class="flex flex-wrap items-end mt-5">
<h3 class="flex-grow font-medium text-lg mb-3">
Views & Submission History
</h3>
<DateInput
:form="filterForm"
name="filter_date"
class="flex-1 !mb-0"
:date-range="true"
:disable-future-dates="true"
:disabled="!form.is_pro"
/>
</div>
<div
class="border border-gray-300 rounded-lg shadow-sm p-4 mb-5 w-full mx-auto mt-4 select-all"
>
<div
v-if="!form.is_pro"
class="relative"
>
<div class="absolute inset-0 z-10">
<div class="p-5 max-w-md mx-auto mt-5">
<p class="text-center">
You need a <pro-tag
upgrade-modal-title="Upgrade today to access form analytics"
class="mx-1"
/> subscription to access your form
analytics.
</p>
<p class="mt-5 text-center">
<v-button
class="w-full"
@click.prevent="subscriptionModalStore.openModal()"
>
Subscribe
</v-button>
</p>
</div>
</div>
<img
src="/img/pages/forms/blurred_graph.png"
alt="Sample Graph"
class="mx-auto filter blur-md z-0"
>
</div>
<Loader
v-else-if="isLoading"
class="h-6 w-6 text-nt-blue mx-auto"
/>
<LineChart
v-else
:options="chartOptions"
:data="chartData"
/>
</div>
<Loader
v-else-if="isLoading"
class="h-6 w-6 text-nt-blue mx-auto"
/>
<LineChart
v-else
:options="chartOptions"
:data="chartData"
/>
</div>
</template>
@@ -55,7 +70,6 @@ import {
CategoryScale,
PointElement,
} from "chart.js"
import ProTag from "~/components/global/ProTag.vue"
ChartJS.register(
Title,
@@ -69,10 +83,7 @@ ChartJS.register(
export default {
name: "FormStats",
components: {
ProTag,
LineChart,
},
components: { LineChart },
props: {
form: {
type: Object,
@@ -81,8 +92,12 @@ export default {
},
setup() {
const subscriptionModalStore = useSubscriptionModalStore()
const filterForm = useForm({
filter_date: null,
})
return {
subscriptionModalStore
subscriptionModalStore,
filterForm
}
},
data() {
@@ -119,8 +134,23 @@ export default {
},
}
},
watch: {
filterForm: {
deep: true,
handler(newVal) {
if(newVal.filter_date && Array.isArray(newVal.filter_date) && newVal.filter_date[0] && newVal.filter_date[1]) {
this.getChartData()
}
}
}
},
mounted() {
this.getChartData()
if (this.form.is_pro) {
const toDate = new Date()
const fromDate = new Date(toDate)
fromDate.setDate(toDate.getDate() - 29)
this.filterForm.filter_date = [fromDate.toISOString().split('T')[0], toDate.toISOString().split('T')[0]]
}
},
methods: {
getChartData() {
@@ -131,6 +161,12 @@ export default {
this.form.workspace_id +
"/form-stats/" +
this.form.id,
{
params: {
date_from: this.filterForm.filter_date[0] ? this.filterForm.filter_date[0].split('T')[0] : null,
date_to: this.filterForm.filter_date[1] ? this.filterForm.filter_date[1].split('T')[0] : null,
}
}
).then((statsData) => {
if (statsData && statsData.views !== undefined) {
this.chartData.labels = Object.keys(statsData.views)
@@ -138,6 +174,9 @@ export default {
this.chartData.datasets[1].data = statsData.submissions
this.isLoading = false
}
}).catch((error) => {
this.isLoading = false
useAlert().error(error.data.message)
})
},
},

View File

@@ -7,6 +7,9 @@ export const pendingSubmission = (form) => {
? form.form_pending_submission_key + "-" + hash(window.location.href)
: ""
})
const formPendingSubmissionTimerKey = computed(() => {
return formPendingSubmissionKey.value + "-timer"
})
const enabled = computed(() => {
return form?.auto_save ?? false
@@ -28,10 +31,27 @@ export const pendingSubmission = (form) => {
return pendingSubmission ? JSON.parse(pendingSubmission) : defaultValue
}
const setTimer = (value) => {
if (import.meta.server) return
useStorage(formPendingSubmissionTimerKey.value).value = value
}
const removeTimer = () => {
return setTimer(null)
}
const getTimer = (defaultValue = null) => {
if (import.meta.server) return
return useStorage(formPendingSubmissionTimerKey.value).value ?? defaultValue
}
return {
enabled,
set,
get,
remove,
setTimer,
removeTimer,
getTimer,
}
}

View File

@@ -1,8 +1,39 @@
<template>
<div class="w-full md:w-4/5 lg:w-3/5 md:mx-auto md:max-w-4xl p-4">
<h3 class="font-semibold mt-4 text-xl">
Form Analytics (last 30 days)
</h3>
<div class="w-full flex flex-col sm:flex-row gap-2">
<div
v-for="(stat, index) in [
{ label: 'Views', value: totalViews, placeholder: '123' },
{ label: 'Submissions', value: totalSubmissions, placeholder: '123' },
{ label: 'Completion Rate', value: completionRate + '%', placeholder: '100%' },
{ label: 'Average Duration', value: averageDuration, placeholder: '10 seconds' }
]"
:key="index"
class="border border-gray-300 rounded-lg shadow-sm p-4 w-full mx-auto"
>
<div class="mb-2 text-sm text-gray-500">
{{ stat.label }}
</div>
<Loader
v-if="isLoading"
class="h-6 w-6 text-nt-blue"
/>
<span
v-else-if="form.is_pro"
class="font-medium text-2xl"
>
{{ stat.value }}
</span>
<span
v-else
class="blur-[3px]"
>
{{ stat.placeholder }}
</span>
</div>
</div>
<form-stats :form="form" />
</div>
</template>
@@ -20,4 +51,33 @@ definePageMeta({
useOpnSeoMeta({
title: props.form ? "Form Analytics - " + props.form.title : "Form Analytics",
})
const isLoading = ref(false)
const totalViews = ref(0)
const totalSubmissions = ref(0)
const completionRate = ref(0)
const averageDuration = ref('-')
onMounted(() => {
getCardData()
})
const getCardData = async() => {
if (!props.form || !props.form.is_pro) { return null }
isLoading.value = true
opnFetch(
"/open/workspaces/" +
props.form.workspace_id +
"/form-stats-details/" +
props.form.id,
).then((responseData) => {
if (responseData) {
totalViews.value = responseData.views ?? 0
totalSubmissions.value = responseData.submissions ?? 0
completionRate.value = Math.min(100,responseData.completion_rate ?? 0)
averageDuration.value = responseData.average_duration ?? '-'
isLoading.value = false
}
})
}
</script>