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

@@ -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
];
}
}

View File

@@ -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([

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Carbon\Carbon;
class FormStatsRequest extends FormRequest
{
public function authorize()
{
return true;
}
public function rules()
{
return [
'date_from' => '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.');
}
});
}
}

View File

@@ -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

View File

@@ -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;
}

View File

@@ -11,12 +11,14 @@ class FormSubmission extends Model
protected $fillable = [
'data',
'completion_time',
];
protected function casts(): array
{
return [
'data' => 'array',
'completion_time' => 'integer',
];
}

View File

@@ -0,0 +1,27 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class () extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('form_submissions', function (Blueprint $table) {
$table->integer('completion_time')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('form_submissions', function (Blueprint $table) {
$table->dropColumn('completion_time');
});
}
};

View File

@@ -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');
});
});
});

View File

@@ -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();
});
});