From ceb0648262d3e17e06ed62e36098291340046917 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala <60499540+chiragchhatrala@users.noreply.github.com> Date: Wed, 18 Sep 2024 22:50:52 +0530 Subject: [PATCH] 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 --- .../Controllers/Forms/FormStatsController.php | 30 +++- .../Forms/PublicFormController.php | 10 +- api/app/Http/Requests/FormStatsRequest.php | 31 ++++ .../Http/Resources/FormSubmissionResource.php | 3 +- api/app/Jobs/Form/StoreFormSubmissionJob.php | 4 +- api/app/Models/Forms/FormSubmission.php | 2 + ...dd_completion_time_to_form_submissions.php | 27 ++++ api/routes/api.php | 1 + api/tests/Feature/Forms/FormStatTest.php | 36 ++++- client/components/open/forms/FormTimer.vue | 57 ++++++++ .../open/forms/OpenCompleteForm.vue | 23 ++- .../open/forms/components/FormStats.vue | 133 +++++++++++------- client/composables/forms/pendingSubmission.js | 20 +++ client/pages/forms/[slug]/show/stats.vue | 66 ++++++++- 14 files changed, 381 insertions(+), 62 deletions(-) create mode 100644 api/app/Http/Requests/FormStatsRequest.php create mode 100644 api/database/migrations/2024_09_09_180618_add_completion_time_to_form_submissions.php create mode 100644 client/components/open/forms/FormTimer.vue diff --git a/api/app/Http/Controllers/Forms/FormStatsController.php b/api/app/Http/Controllers/Forms/FormStatsController.php index efb133ff..9ef38df4 100644 --- a/api/app/Http/Controllers/Forms/FormStatsController.php +++ b/api/app/Http/Controllers/Forms/FormStatsController.php @@ -3,7 +3,9 @@ namespace App\Http\Controllers\Forms; use App\Http\Controllers\Controller; +use App\Http\Requests\FormStatsRequest; use Carbon\CarbonPeriod; +use Carbon\CarbonInterval; use Illuminate\Http\Request; class FormStatsController extends Controller @@ -13,14 +15,14 @@ class FormStatsController extends Controller $this->middleware('auth'); } - public function getFormStats(Request $request) + public function getFormStats(FormStatsRequest $request) { $form = $request->form; // Added by ProForm middleware $this->authorize('view', $form); - $formStats = $form->statistics()->where('date', '>', now()->subDays(29)->startOfDay())->get(); + $formStats = $form->statistics()->whereBetween('date', [$request->date_from, $request->date_to])->get(); $periodStats = ['views' => [], 'submissions' => []]; - foreach (CarbonPeriod::create(now()->subDays(29), now()) as $dateObj) { + foreach (CarbonPeriod::create($request->date_from, $request->date_to) as $dateObj) { $date = $dateObj->format('d-m-Y'); $statisticData = $formStats->where('date', $dateObj->format('Y-m-d'))->first(); @@ -34,4 +36,26 @@ class FormStatsController extends Controller return $periodStats; } + + public function getFormStatsDetails(Request $request) + { + $form = $request->form; // Added by ProForm middleware + $this->authorize('view', $form); + + $totalViews = $form->views()->count(); + $totalSubmissions = $form->submissions_count; + + $averageDuration = \Cache::remember('form_stats_average_duration_' . $form->id, 1800, function () use ($form) { + $submissionsWithDuration = $form->submissions()->whereNotNull('completion_time')->count() ?? 0; + $totalDuration = $form->submissions()->whereNotNull('completion_time')->sum('completion_time') ?? 0; + return $submissionsWithDuration > 0 ? round($totalDuration / $submissionsWithDuration) : null; + }); + + return [ + 'views' => $totalViews, + 'submissions' => $totalSubmissions, + 'completion_rate' => $totalViews > 0 ? round(($totalSubmissions / $totalViews) * 100, 2) : 0, + 'average_duration' => $averageDuration ? CarbonInterval::seconds($averageDuration)->cascade()->forHumans() : null + ]; + } } diff --git a/api/app/Http/Controllers/Forms/PublicFormController.php b/api/app/Http/Controllers/Forms/PublicFormController.php index e1c27fc6..67e1134a 100644 --- a/api/app/Http/Controllers/Forms/PublicFormController.php +++ b/api/app/Http/Controllers/Forms/PublicFormController.php @@ -77,7 +77,7 @@ class PublicFormController extends Controller $internal_url = Storage::temporaryUrl($path, now()->addMinutes(5)); - foreach(config('filesystems.disks.s3.temporary_url_rewrites') as $from => $to) { + foreach (config('filesystems.disks.s3.temporary_url_rewrites') as $from => $to) { $internal_url = str_replace($from, $to, $internal_url); } @@ -89,12 +89,16 @@ class PublicFormController extends Controller $form = $request->form; $submissionId = false; + $submissionData = $request->validated(); + $completionTime = $request->get('completion_time') ?? null; + unset($submissionData['completion_time']); // Remove completion_time from the main data array + if ($form->editable_submissions) { - $job = new StoreFormSubmissionJob($form, $request->validated()); + $job = new StoreFormSubmissionJob($form, $submissionData, $completionTime); $job->handle(); $submissionId = Hashids::encode($job->getSubmissionId()); } else { - StoreFormSubmissionJob::dispatch($form, $request->validated()); + StoreFormSubmissionJob::dispatch($form, $submissionData, $completionTime); } return $this->success(array_merge([ diff --git a/api/app/Http/Requests/FormStatsRequest.php b/api/app/Http/Requests/FormStatsRequest.php new file mode 100644 index 00000000..a1451e9e --- /dev/null +++ b/api/app/Http/Requests/FormStatsRequest.php @@ -0,0 +1,31 @@ + 'required|date', + 'date_to' => 'required|date|after_or_equal:date_from', + ]; + } + + public function withValidator($validator) + { + $validator->after(function ($validator) { + if (Carbon::parse($this->date_from)->diffInMonths(Carbon::parse($this->date_to)) > 3) { + $validator->errors()->add('date_range', 'Date range exceeds 3 months. Please select a shorter period.'); + } + }); + } +} diff --git a/api/app/Http/Resources/FormSubmissionResource.php b/api/app/Http/Resources/FormSubmissionResource.php index 4a63a812..4572db0f 100644 --- a/api/app/Http/Resources/FormSubmissionResource.php +++ b/api/app/Http/Resources/FormSubmissionResource.php @@ -23,7 +23,8 @@ class FormSubmissionResource extends JsonResource } return array_merge([ - 'data' => $this->data + 'data' => $this->data, + 'completion_time' => $this->completion_time, ], ($this->publiclyAccessed) ? [] : [ 'form_id' => $this->form_id, 'id' => $this->id diff --git a/api/app/Jobs/Form/StoreFormSubmissionJob.php b/api/app/Jobs/Form/StoreFormSubmissionJob.php index 3cbaf224..6780244c 100644 --- a/api/app/Jobs/Form/StoreFormSubmissionJob.php +++ b/api/app/Jobs/Form/StoreFormSubmissionJob.php @@ -33,7 +33,7 @@ class StoreFormSubmissionJob implements ShouldQueue * * @return void */ - public function __construct(public Form $form, public array $submissionData) + public function __construct(public Form $form, public array $submissionData, public ?int $completionTime = null) { } @@ -70,11 +70,13 @@ class StoreFormSubmissionJob implements ShouldQueue // Create or update record if ($previousSubmission = $this->submissionToUpdate()) { $previousSubmission->data = $formData; + $previousSubmission->completion_time = $this->completionTime; $previousSubmission->save(); $this->submissionId = $previousSubmission->id; } else { $response = $this->form->submissions()->create([ 'data' => $formData, + 'completion_time' => $this->completionTime, ]); $this->submissionId = $response->id; } diff --git a/api/app/Models/Forms/FormSubmission.php b/api/app/Models/Forms/FormSubmission.php index 0953bd7d..003a9caa 100644 --- a/api/app/Models/Forms/FormSubmission.php +++ b/api/app/Models/Forms/FormSubmission.php @@ -11,12 +11,14 @@ class FormSubmission extends Model protected $fillable = [ 'data', + 'completion_time', ]; protected function casts(): array { return [ 'data' => 'array', + 'completion_time' => 'integer', ]; } diff --git a/api/database/migrations/2024_09_09_180618_add_completion_time_to_form_submissions.php b/api/database/migrations/2024_09_09_180618_add_completion_time_to_form_submissions.php new file mode 100644 index 00000000..37d48c5e --- /dev/null +++ b/api/database/migrations/2024_09_09_180618_add_completion_time_to_form_submissions.php @@ -0,0 +1,27 @@ +integer('completion_time')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('form_submissions', function (Blueprint $table) { + $table->dropColumn('completion_time'); + }); + } +}; diff --git a/api/routes/api.php b/api/routes/api.php index c0376e2c..7712602f 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -146,6 +146,7 @@ Route::group(['middleware' => 'auth:api'], function () { Route::middleware('pro-form')->group(function () { Route::get('form-stats/{formId}', [FormStatsController::class, 'getFormStats'])->name('form.stats'); + Route::get('form-stats-details/{formId}', [FormStatsController::class, 'getFormStatsDetails'])->name('form.stats-details'); }); }); }); diff --git a/api/tests/Feature/Forms/FormStatTest.php b/api/tests/Feature/Forms/FormStatTest.php index 9051937e..abe98f4e 100644 --- a/api/tests/Feature/Forms/FormStatTest.php +++ b/api/tests/Feature/Forms/FormStatTest.php @@ -39,7 +39,7 @@ it('check formstat chart data', function () { } // Now check chart data - $this->getJson(route('open.workspaces.form.stats', [$workspace->id, $form->id])) + $this->getJson(route('open.workspaces.form.stats', [$workspace->id, $form->id]) . '?date_from=' . now()->subDays(29)->format('Y-m-d') . '&date_to=' . now()->format('Y-m-d')) ->assertSuccessful() ->assertJson(function (\Illuminate\Testing\Fluent\AssertableJson $json) use ($views, $submissions) { return $json->whereType('views', 'array') @@ -65,3 +65,37 @@ it('check formstat chart data', function () { ->etc(); }); }); + + +it('checks form stats details', function () { + $user = $this->actingAsProUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace, []); + + // Create form submissions with varying completion times + $form->submissions()->createMany([ + ['completion_time' => 60], // 1 minute + ['completion_time' => 60], // 1 minute + ['completion_time' => 60], // 1 minute + ['completion_time' => 120], // 2 minutes + ['completion_time' => 120], // 2 minutes + [] // Incomplete submission + ]); + + // Create form views + $form->views()->createMany(array_fill(0, 10, [])); + + $this->getJson(route('open.workspaces.form.stats-details', [$workspace->id, $form->id])) + ->assertSuccessful() + ->assertJson(function (\Illuminate\Testing\Fluent\AssertableJson $json) { + return $json->has('views') + ->has('submissions') + ->has('completion_rate') + ->has('average_duration') + ->where('views', 10) + ->where('submissions', 6) + ->where('completion_rate', 60) + ->where('average_duration', '1 minute 24 seconds') + ->etc(); + }); +}); diff --git a/client/components/open/forms/FormTimer.vue b/client/components/open/forms/FormTimer.vue new file mode 100644 index 00000000..0b1fe5c6 --- /dev/null +++ b/client/components/open/forms/FormTimer.vue @@ -0,0 +1,57 @@ + \ No newline at end of file diff --git a/client/components/open/forms/OpenCompleteForm.vue b/client/components/open/forms/OpenCompleteForm.vue index fc9b7686..33f02324 100644 --- a/client/components/open/forms/OpenCompleteForm.vue +++ b/client/components/open/forms/OpenCompleteForm.vue @@ -4,6 +4,11 @@ class="open-complete-form" :style="{ '--font-family': form.font_family }" > + + 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) { diff --git a/client/components/open/forms/components/FormStats.vue b/client/components/open/forms/components/FormStats.vue index 445dcbf4..22498dbb 100644 --- a/client/components/open/forms/components/FormStats.vue +++ b/client/components/open/forms/components/FormStats.vue @@ -1,45 +1,60 @@ @@ -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) }) }, }, diff --git a/client/composables/forms/pendingSubmission.js b/client/composables/forms/pendingSubmission.js index 033dac25..0a7ffc95 100644 --- a/client/composables/forms/pendingSubmission.js +++ b/client/composables/forms/pendingSubmission.js @@ -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, } } diff --git a/client/pages/forms/[slug]/show/stats.vue b/client/pages/forms/[slug]/show/stats.vue index 800bc43d..b853b3f5 100644 --- a/client/pages/forms/[slug]/show/stats.vue +++ b/client/pages/forms/[slug]/show/stats.vue @@ -1,8 +1,39 @@ @@ -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 + } + }) +}