diff --git a/api/app/Http/Controllers/Forms/FormStatsController.php b/api/app/Http/Controllers/Forms/FormStatsController.php index e24a2506..eb6fedc6 100644 --- a/api/app/Http/Controllers/Forms/FormStatsController.php +++ b/api/app/Http/Controllers/Forms/FormStatsController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Forms; use App\Http\Controllers\Controller; use App\Http\Requests\FormStatsRequest; +use App\Models\Forms\FormSubmission; use Carbon\CarbonPeriod; use Carbon\CarbonInterval; use Illuminate\Http\Request; @@ -21,13 +22,14 @@ class FormStatsController extends Controller $this->authorize('view', $form); $formStats = $form->statistics()->whereBetween('date', [$request->date_from, $request->date_to])->get(); - $periodStats = ['views' => [], 'submissions' => []]; + $periodStats = ['views' => [], 'submissions' => [], 'partial_submissions' => []]; 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(); $periodStats['views'][$date] = $statisticData->data['views'] ?? 0; - $periodStats['submissions'][$date] = $form->submissions()->whereDate('created_at', $dateObj)->count(); + $periodStats['submissions'][$date] = $form->submissions()->whereDate('created_at', $dateObj)->where('status', FormSubmission::STATUS_COMPLETED)->count(); + $periodStats['partial_submissions'][$date] = $form->submissions()->whereDate('created_at', $dateObj)->where('status', FormSubmission::STATUS_PARTIAL)->count(); if ($dateObj->toDateString() === now()->toDateString()) { $periodStats['views'][$date] += $form->views()->count(); diff --git a/api/app/Http/Controllers/Forms/FormSubmissionController.php b/api/app/Http/Controllers/Forms/FormSubmissionController.php index 8a15bc12..65ff02f1 100644 --- a/api/app/Http/Controllers/Forms/FormSubmissionController.php +++ b/api/app/Http/Controllers/Forms/FormSubmissionController.php @@ -36,8 +36,11 @@ class FormSubmissionController extends Controller { $form = $request->form; $this->authorize('update', $form); - $job = new StoreFormSubmissionJob($request->form, $request->validated()); - $job->setSubmissionId($submissionId)->handle(); + + $submissionData = $request->validated(); + $submissionData['submission_id'] = $submissionId; + $job = new StoreFormSubmissionJob($request->form, $submissionData); + $job->handle(); $data = new FormSubmissionResource(FormSubmission::findOrFail($submissionId)); diff --git a/api/app/Http/Controllers/Forms/PublicFormController.php b/api/app/Http/Controllers/Forms/PublicFormController.php index d1669c03..3dd542a5 100644 --- a/api/app/Http/Controllers/Forms/PublicFormController.php +++ b/api/app/Http/Controllers/Forms/PublicFormController.php @@ -16,6 +16,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Storage; use Vinkla\Hashids\Facades\Hashids; +use Illuminate\Support\Str; class PublicFormController extends Controller { @@ -85,38 +86,128 @@ class PublicFormController extends Controller return redirect()->to($internal_url); } + /** + * Handle partial form submissions + * + * @param Request $request + * @return \Illuminate\Http\JsonResponse + */ + private function handlePartialSubmissions(Request $request) + { + $form = $request->form; + + // Process submission data to extract submission ID + $submissionData = $this->processSubmissionIdentifiers($request, $request->all()); + + // Validate that at least one field has a value + $hasValue = false; + foreach ($submissionData as $key => $value) { + if (Str::isUuid($key) && !empty($value)) { + $hasValue = true; + break; + } + } + if (!$hasValue) { + return $this->error([ + 'message' => 'At least one field must have a value for partial submissions.' + ], 422); + } + + // Explicitly mark this as a partial submission + $submissionData['is_partial'] = true; + + // Use the same job as regular submissions to ensure consistent processing + $job = new StoreFormSubmissionJob($form, $submissionData); + $job->handle(); + + // Get the submission ID + $submissionId = $job->getSubmissionId(); + + return $this->success([ + 'message' => 'Partial submission saved', + 'submission_hash' => Hashids::encode($submissionId) + ]); + } + public function answer(AnswerFormRequest $request, FormSubmissionProcessor $formSubmissionProcessor) { $form = $request->form; $isFirstSubmission = ($form->submissions_count === 0); - $submissionId = false; + // Handle partial submissions + $isPartial = $request->get('is_partial') ?? false; + if ($isPartial && $form->enable_partial_submissions && $form->is_pro) { + return $this->handlePartialSubmissions($request); + } + + // Get validated data (includes all metadata) $submissionData = $request->validated(); - $completionTime = $request->get('completion_time') ?? null; - unset($submissionData['completion_time']); // Remove completion_time from the main data array - $job = new StoreFormSubmissionJob($form, $submissionData, $completionTime); + // Process submission hash and ID + $submissionData = $this->processSubmissionIdentifiers($request, $submissionData); + // Create the job with all data (including metadata) + $job = new StoreFormSubmissionJob($form, $submissionData); + + // Process the submission if ($formSubmissionProcessor->shouldProcessSynchronously($form)) { $job->handle(); - $submissionId = Hashids::encode($job->getSubmissionId()); + $encodedSubmissionId = Hashids::encode($job->getSubmissionId()); // Update submission data with generated values for redirect URL $submissionData = $job->getProcessedData(); } else { - StoreFormSubmissionJob::dispatch($form, $submissionData, $completionTime); + $job->handle(); + $encodedSubmissionId = Hashids::encode($job->getSubmissionId()); } + // Return the response return $this->success(array_merge([ 'message' => 'Form submission saved.', - 'submission_id' => $submissionId, + 'submission_id' => $encodedSubmissionId, 'is_first_submission' => $isFirstSubmission, ], $formSubmissionProcessor->getRedirectData($form, $submissionData))); } + /** + * Processes submission identifiers to ensure consistent numeric format + * + * Takes a submission hash or string ID and converts it to a numeric submission_id. + * This allows submissions to be identified by either a hashed value or direct ID + * while ensuring consistent internal storage format. + * + * @param Request $request + * @param array $submissionData + * @return array + */ + private function processSubmissionIdentifiers(Request $request, array $submissionData): array + { + // Handle submission hash if present (convert to numeric submission_id) + $submissionHash = $request->get('submission_hash'); + if ($submissionHash) { + $decodedHash = Hashids::decode($submissionHash); + if (!empty($decodedHash)) { + $submissionData['submission_id'] = (int)($decodedHash[0] ?? null); + } + unset($submissionData['submission_hash']); + } + + // Handle string submission_id if present (convert to numeric) + if (isset($submissionData['submission_id']) && is_string($submissionData['submission_id']) && !is_numeric($submissionData['submission_id'])) { + $decodedId = Hashids::decode($submissionData['submission_id']); + if (!empty($decodedId)) { + $submissionData['submission_id'] = (int)($decodedId[0] ?? null); + } + } + + return $submissionData; + } + public function fetchSubmission(Request $request, string $slug, string $submissionId) { - $submissionId = ($submissionId) ? Hashids::decode($submissionId) : false; - $submissionId = isset($submissionId[0]) ? $submissionId[0] : false; + // Decode the submission ID using the same approach as in processSubmissionIdentifiers + $decodedId = Hashids::decode($submissionId); + $submissionId = !empty($decodedId) ? (int)($decodedId[0]) : false; + $form = Form::whereSlug($slug)->whereVisibility('public')->firstOrFail(); if ($form->workspace == null || !$form->editable_submissions || !$submissionId) { return $this->error([ diff --git a/api/app/Http/Requests/AnswerFormRequest.php b/api/app/Http/Requests/AnswerFormRequest.php index cb19bb70..fb08658a 100644 --- a/api/app/Http/Requests/AnswerFormRequest.php +++ b/api/app/Http/Requests/AnswerFormRequest.php @@ -54,6 +54,11 @@ class AnswerFormRequest extends FormRequest */ public function rules() { + // Skip validation if this is a partial submission + if ($this->has('is_partial')) { + return []; + } + $selectionFields = collect($this->form->properties)->filter(function ($pro) { return in_array($pro['type'], ['select', 'multi_select']); }); diff --git a/api/app/Http/Requests/UserFormRequest.php b/api/app/Http/Requests/UserFormRequest.php index 05b3c98f..8a36e661 100644 --- a/api/app/Http/Requests/UserFormRequest.php +++ b/api/app/Http/Requests/UserFormRequest.php @@ -131,6 +131,7 @@ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest 'show_progress_bar' => 'boolean', 'auto_save' => 'boolean', 'auto_focus' => 'boolean', + 'enable_partial_submissions' => 'boolean', // Properties 'properties' => 'required|array', diff --git a/api/app/Http/Resources/FormSubmissionResource.php b/api/app/Http/Resources/FormSubmissionResource.php index 4572db0f..02825216 100644 --- a/api/app/Http/Resources/FormSubmissionResource.php +++ b/api/app/Http/Resources/FormSubmissionResource.php @@ -40,6 +40,7 @@ class FormSubmissionResource extends JsonResource private function addExtraData() { $this->data = array_merge($this->data, [ + 'status' => $this->status, 'created_at' => $this->created_at->toDateTimeString(), 'id' => $this->id, ]); diff --git a/api/app/Jobs/Form/StoreFormSubmissionJob.php b/api/app/Jobs/Form/StoreFormSubmissionJob.php index ce481919..1672b12b 100644 --- a/api/app/Jobs/Form/StoreFormSubmissionJob.php +++ b/api/app/Jobs/Form/StoreFormSubmissionJob.php @@ -17,8 +17,27 @@ use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; -use Vinkla\Hashids\Facades\Hashids; +use Illuminate\Support\Facades\Log; +/** + * Job to store form submissions + * + * This job handles the storage of form submissions, including processing of metadata + * and special field types like files and signatures. + * + * The job accepts all data in the submissionData array, including metadata fields: + * - submission_id: ID of an existing submission to update (must be an integer) + * - completion_time: Time in seconds it took to complete the form + * - is_partial: Whether this is a partial submission (will be stored with STATUS_PARTIAL) + * If not specified, submissions are treated as complete by default. + * + * These metadata fields will be automatically extracted and removed from the stored form data. + * + * For partial submissions: + * - The submission will be stored with STATUS_PARTIAL + * - All file uploads and signatures will be processed normally + * - The submission can later be updated to STATUS_COMPLETED when the user completes the form + */ class StoreFormSubmissionJob implements ShouldQueue { use Dispatchable; @@ -26,15 +45,19 @@ class StoreFormSubmissionJob implements ShouldQueue use Queueable; use SerializesModels; - public ?string $submissionId = null; + public ?int $submissionId = null; private ?array $formData = null; + private ?int $completionTime = null; + private bool $isPartial = false; /** * Create a new job instance. * + * @param Form $form The form being submitted + * @param array $submissionData Form data including metadata fields (submission_id, completion_time, etc.) * @return void */ - public function __construct(public Form $form, public array $submissionData, public ?int $completionTime = null) + public function __construct(public Form $form, public array $submissionData) { } @@ -45,60 +68,95 @@ class StoreFormSubmissionJob implements ShouldQueue */ public function handle() { + // Extract metadata from submission data + $this->extractMetadata(); + + // Process form data $this->formData = $this->getFormData(); $this->addHiddenPrefills($this->formData); + // Store the submission $this->storeSubmission($this->formData); + // Add the submission ID to the form data after storing the submission $this->formData['submission_id'] = $this->submissionId; - FormSubmitted::dispatch($this->form, $this->formData); + + // Only trigger integrations for completed submissions, not partial ones + if (!$this->isPartial) { + FormSubmitted::dispatch($this->form, $this->formData); + } } + /** + * Extract metadata from submission data + * + * This method extracts and removes metadata fields from the submission data: + * - submission_id + * - completion_time + * - is_partial + */ + private function extractMetadata(): void + { + // Extract completion time + if (isset($this->submissionData['completion_time'])) { + $this->completionTime = $this->submissionData['completion_time']; + unset($this->submissionData['completion_time']); + } + + // Extract direct submission ID if present + if (isset($this->submissionData['submission_id']) && $this->submissionData['submission_id']) { + if (is_numeric($this->submissionData['submission_id'])) { + $this->submissionId = (int)$this->submissionData['submission_id']; + } + unset($this->submissionData['submission_id']); + } + + // Extract is_partial flag if present, otherwise default to false + if (isset($this->submissionData['is_partial'])) { + $this->isPartial = (bool)$this->submissionData['is_partial']; + unset($this->submissionData['is_partial']); + } + } + + /** + * Get the submission ID + * + * @return int|null + */ public function getSubmissionId() { return $this->submissionId; } - public function setSubmissionId(int $id) - { - $this->submissionId = $id; - - return $this; - } - + /** + * Store the submission in the database + * + * @param array $formData + */ private function storeSubmission(array $formData) { - // 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; - } - } + // Find existing submission or create a new one + $submission = $this->submissionId + ? $this->form->submissions()->findOrFail($this->submissionId) + : new FormSubmission(); - /** - * Search for Submission record to update and returns it - */ - private function submissionToUpdate(): ?FormSubmission - { - if ($this->submissionId) { - return $this->form->submissions()->findOrFail($this->submissionId); - } - if ($this->form->editable_submissions && isset($this->submissionData['submission_id']) && $this->submissionData['submission_id']) { - $submissionId = $this->submissionData['submission_id'] ? Hashids::decode($this->submissionData['submission_id']) : false; - $submissionId = $submissionId[0] ?? null; - - return $this->form->submissions()->findOrFail($submissionId); + // Set submission properties + if (!$this->submissionId) { + $submission->form_id = $this->form->id; } - return null; + $submission->data = $formData; + $submission->completion_time = $this->completionTime; + + // Set the status based on whether this is a partial submission + $submission->status = $this->isPartial + ? FormSubmission::STATUS_PARTIAL + : FormSubmission::STATUS_COMPLETED; + + $submission->save(); + + // Store the submission ID + $this->submissionId = $submission->id; } /** @@ -209,7 +267,7 @@ class StoreFormSubmissionJob implements ShouldQueue $newPath = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $this->form->id); $completeNewFilename = $newPath . '/' . $fileNameParser->getMovedFileName(); - \Log::debug('Moving file to permanent storage.', [ + Log::debug('Moving file to permanent storage.', [ 'uuid' => $fileNameParser->uuid, 'destination' => $completeNewFilename, 'form_id' => $this->form->id, @@ -264,13 +322,20 @@ class StoreFormSubmissionJob implements ShouldQueue } /** - * Get the processed form data after all transformations + * Get the processed form data including the submission ID + * + * @return array */ public function getProcessedData(): array { if ($this->formData === null) { $this->formData = $this->getFormData(); } - return $this->formData; + + // Ensure the submission ID is included in the returned data + $data = $this->formData; + $data['submission_id'] = $this->submissionId; + + return $data; } } diff --git a/api/app/Models/Forms/Form.php b/api/app/Models/Forms/Form.php index c709ad31..42b6f2ec 100644 --- a/api/app/Models/Forms/Form.php +++ b/api/app/Models/Forms/Form.php @@ -92,6 +92,7 @@ class Form extends Model implements CachableAttributes 'show_progress_bar', 'auto_save', 'auto_focus', + 'enable_partial_submissions', // Security & Privacy 'can_be_indexed', @@ -110,6 +111,7 @@ class Form extends Model implements CachableAttributes 'tags' => 'array', 'removed_properties' => 'array', 'seo_meta' => 'object', + 'enable_partial_submissions' => 'boolean', 'auto_save' => 'boolean', ]; } @@ -174,7 +176,7 @@ class Form extends Model implements CachableAttributes public function getSubmissionsCountAttribute() { - return $this->submissions()->count(); + return $this->submissions()->where('status', FormSubmission::STATUS_COMPLETED)->count(); } public function getViewsCountAttribute() diff --git a/api/app/Models/Forms/FormSubmission.php b/api/app/Models/Forms/FormSubmission.php index 003a9caa..57613560 100644 --- a/api/app/Models/Forms/FormSubmission.php +++ b/api/app/Models/Forms/FormSubmission.php @@ -9,9 +9,13 @@ class FormSubmission extends Model { use HasFactory; + public const STATUS_PARTIAL = 'partial'; + public const STATUS_COMPLETED = 'completed'; + protected $fillable = [ 'data', 'completion_time', + 'status' ]; protected function casts(): array diff --git a/api/app/Service/Forms/FormCleaner.php b/api/app/Service/Forms/FormCleaner.php index 91878f3a..7670b005 100644 --- a/api/app/Service/Forms/FormCleaner.php +++ b/api/app/Service/Forms/FormCleaner.php @@ -33,7 +33,8 @@ class FormCleaner 'editable_submissions' => false, 'custom_code' => null, 'seo_meta' => [], - 'redirect_url' => null + 'redirect_url' => null, + 'enable_partial_submissions' => false, ]; private array $formNonTrialingDefaults = [ @@ -54,6 +55,7 @@ class FormCleaner 'custom_code' => 'Custom code was disabled', 'seo_meta' => 'Custom SEO was disabled', 'redirect_url' => 'Redirect Url was disabled', + 'enable_partial_submissions' => 'Partial submissions were disabled', // For fields 'file_upload' => 'Link field is not a file upload.', diff --git a/api/app/Service/Forms/FormLogicConditionChecker.php b/api/app/Service/Forms/FormLogicConditionChecker.php index 5fe1fb35..0603f015 100644 --- a/api/app/Service/Forms/FormLogicConditionChecker.php +++ b/api/app/Service/Forms/FormLogicConditionChecker.php @@ -320,6 +320,7 @@ class FormLogicConditionChecker } return FormSubmission::where('form_id', $formId) + ->where('status', '!=', FormSubmission::STATUS_PARTIAL) ->where(function ($query) use ($condition, $fieldValue) { $fieldId = $condition['property_meta']['id']; diff --git a/api/database/migrations/2025_02_14_073642_add_partial_submissions_to_form_submissions.php b/api/database/migrations/2025_02_14_073642_add_partial_submissions_to_form_submissions.php new file mode 100644 index 00000000..24a26e08 --- /dev/null +++ b/api/database/migrations/2025_02_14_073642_add_partial_submissions_to_form_submissions.php @@ -0,0 +1,38 @@ +enum('status', [FormSubmission::STATUS_PARTIAL, FormSubmission::STATUS_COMPLETED]) + ->default(FormSubmission::STATUS_COMPLETED) + ->index(); + }); + + Schema::table('forms', function (Blueprint $table) { + $table->boolean('enable_partial_submissions')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('form_submissions', function (Blueprint $table) { + $table->dropColumn('status'); + }); + + Schema::table('forms', function (Blueprint $table) { + $table->dropColumn('enable_partial_submissions'); + }); + } +}; diff --git a/api/tests/Feature/Submissions/EditSubmissionTest.php b/api/tests/Feature/Submissions/EditSubmissionTest.php index 2f00b7c4..cd578009 100644 --- a/api/tests/Feature/Submissions/EditSubmissionTest.php +++ b/api/tests/Feature/Submissions/EditSubmissionTest.php @@ -1,7 +1,5 @@ actingAsUser(); $workspace = $this->createUserWorkspace($user); diff --git a/api/tests/Feature/Submissions/PartialSubmissionTest.php b/api/tests/Feature/Submissions/PartialSubmissionTest.php new file mode 100644 index 00000000..b5d8ce84 --- /dev/null +++ b/api/tests/Feature/Submissions/PartialSubmissionTest.php @@ -0,0 +1,237 @@ +actingAsProUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace, [ + 'enable_partial_submissions' => true + ]); + + // Initial partial submission + $formData = $this->generateFormSubmissionData($form, ['text' => 'Initial Text']); + $formData['is_partial'] = true; + + $partialResponse = $this->postJson(route('forms.answer', $form->slug), $formData) + ->assertSuccessful() + ->assertJson([ + 'type' => 'success', + 'message' => 'Partial submission saved', + ]); + + $submissionHash = $partialResponse->json('submission_hash'); + expect($submissionHash)->not->toBeEmpty(); + + // Complete the submission using the hash + $completeData = $this->generateFormSubmissionData($form, [ + 'text' => 'Complete Text', + 'email' => 'test@example.com' + ]); + $completeData['submission_hash'] = $submissionHash; + + $this->postJson(route('forms.answer', $form->slug), $completeData) + ->assertSuccessful() + ->assertJson([ + 'type' => 'success', + 'message' => 'Form submission saved.', + ]); + + // Verify final submission state + $submission = FormSubmission::first(); + expect($submission->status)->toBe(FormSubmission::STATUS_COMPLETED); + expect($submission->data)->toHaveKey(array_key_first($completeData)); +}); + +it('can update partial submission multiple times', function () { + $user = $this->actingAsProUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace, [ + 'enable_partial_submissions' => true + ]); + $targetField = collect($form->properties)->where('name', 'Name')->first(); + + // First partial submission + $formData = $this->generateFormSubmissionData($form, [$targetField['id'] => 'First Draft']); + $formData['is_partial'] = true; + + $firstResponse = $this->postJson(route('forms.answer', $form->slug), $formData) + ->assertSuccessful(); + + $submissionHash = $firstResponse->json('submission_hash'); + + // Second partial update + $secondData = $this->generateFormSubmissionData($form, [$targetField['id'] => 'Second Draft']); + $secondData['is_partial'] = true; + $secondData['submission_hash'] = $submissionHash; + + $this->postJson(route('forms.answer', $form->slug), $secondData) + ->assertSuccessful(); + + // Verify submission was updated + $submission = FormSubmission::first(); + expect($submission->status)->toBe(FormSubmission::STATUS_PARTIAL); + expect($submission->data)->toHaveKey(array_key_first($secondData)); + expect($submission->data[array_key_first($secondData)])->toBe('Second Draft'); +}); + +it('calculates stats correctly for partial vs completed submissions', function () { + $user = $this->actingAsProUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace, [ + 'enable_partial_submissions' => true + ]); + + // Create partial submission + $partialData = $this->generateFormSubmissionData($form, ['text' => 'Partial']); + $partialData['is_partial'] = true; + $this->postJson(route('forms.answer', $form->slug), $partialData); + + // Create completed submission + $completeData = $this->generateFormSubmissionData($form, ['text' => 'Complete']); + $this->postJson(route('forms.answer', $form->slug), $completeData); + + // Verify stats + $form->refresh(); + expect($form->submissions()->where('status', FormSubmission::STATUS_PARTIAL)->count())->toBe(1); + expect($form->submissions()->where('status', FormSubmission::STATUS_COMPLETED)->count())->toBe(1); +}); + +it('handles file uploads in partial submissions', function () { + Storage::fake(); + + $user = $this->actingAsProUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace, [ + 'enable_partial_submissions' => true + ]); + + // Create a fake file + $file = UploadedFile::fake()->create('test.pdf', 100); + + // First partial submission with file + $formData = $this->generateFormSubmissionData($form); + $fileFieldId = collect($form->properties)->where('type', 'files')->first()['id']; + $formData[$fileFieldId] = $file; + $formData['is_partial'] = true; + + $response = $this->postJson(route('forms.answer', $form->slug), $formData) + ->assertSuccessful(); + + $submissionHash = $response->json('submission_hash'); + + // Complete the submission + $completeData = $this->generateFormSubmissionData($form, ['text' => 'Complete']); + $completeData['submission_hash'] = $submissionHash; + + $this->postJson(route('forms.answer', $form->slug), $completeData) + ->assertSuccessful(); + + // Verify file was preserved + $submission = FormSubmission::first(); + expect($submission->data)->toHaveKey($fileFieldId); + $filePath = str_replace('storage/', '', $submission->data[$fileFieldId]); + expect(Storage::disk('local')->exists($filePath))->toBeTrue(); +}); + +it('handles signature field in partial submissions', function () { + Storage::fake(); + + $user = $this->actingAsProUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace, [ + 'enable_partial_submissions' => true + ]); + + // Create partial submission with signature + $formData = $this->generateFormSubmissionData($form); + $signatureFieldId = collect($form->properties)->where('type', 'files')->first()['id']; + $formData[$signatureFieldId] = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...'; // Base64 signature data + $formData['is_partial'] = true; + + $response = $this->postJson(route('forms.answer', $form->slug), $formData) + ->assertSuccessful(); + + $submissionHash = $response->json('submission_hash'); + + // Complete the submission + $completeData = $this->generateFormSubmissionData($form, ['text' => 'Complete']); + $completeData['submission_hash'] = $submissionHash; + + $this->postJson(route('forms.answer', $form->slug), $completeData) + ->assertSuccessful(); + + // Verify signature was preserved + $submission = FormSubmission::first(); + expect($submission->data)->toHaveKey($signatureFieldId); + $filePath = str_replace('storage/', '', $submission->data[$signatureFieldId]); + expect(Storage::disk('local')->exists($filePath))->toBeTrue(); +}); + +it('requires at least one field with value for partial submission', function () { + $user = $this->actingAsProUser(); + $workspace = $this->createUserWorkspace($user); + $form = $this->createForm($user, $workspace, [ + 'enable_partial_submissions' => true + ]); + + // Try to submit with empty data + $formData = ['is_partial' => true]; + + $this->postJson(route('forms.answer', $form->slug), $formData) + ->assertStatus(422) + ->assertJson([ + 'message' => 'At least one field must have a value for partial submissions.' + ]); +}); + +it('submits as completed when partial feature is disabled', function () { + $user = $this->actingAsProUser(); + $workspace = $this->createUserWorkspace($user); + + // Create form with partial submissions disabled + $form = $this->createForm($user, $workspace, [ + 'enable_partial_submissions' => false + ]); + + $formData = $this->generateFormSubmissionData($form, ['text' => 'Test']); + $formData['is_partial'] = true; + + $this->postJson(route('forms.answer', $form->slug), $formData) + ->assertSuccessful() + ->assertJson([ + 'type' => 'success', + 'message' => 'Form submission saved.', + ]); + + // Verify submission was saved as completed + $submission = FormSubmission::first(); + expect($submission->status)->toBe(FormSubmission::STATUS_COMPLETED); +}); + +it('submits as completed on non-pro forms', function () { + $user = $this->actingAsUser(); + $workspace = $this->createUserWorkspace($user); + + // Create non-pro form with partial submissions enabled + $form = $this->createForm($user, $workspace, [ + 'enable_partial_submissions' => true + ]); + + $formData = $this->generateFormSubmissionData($form, ['text' => 'Test']); + $formData['is_partial'] = true; + + $this->postJson(route('forms.answer', $form->slug), $formData) + ->assertSuccessful() + ->assertJson([ + 'type' => 'success', + 'message' => 'Form submission saved.', + ]); + + // Verify submission was saved as completed + $submission = FormSubmission::first(); + expect($submission->status)->toBe(FormSubmission::STATUS_COMPLETED); +}); diff --git a/client/components/open/forms/OpenCompleteForm.vue b/client/components/open/forms/OpenCompleteForm.vue index cd6c8288..d1b6049b 100644 --- a/client/components/open/forms/OpenCompleteForm.vue +++ b/client/components/open/forms/OpenCompleteForm.vue @@ -190,7 +190,8 @@ import OpenForm from './OpenForm.vue' import OpenFormButton from './OpenFormButton.vue' import FormCleanings from '../../pages/forms/show/FormCleanings.vue' import VTransition from '~/components/global/transitions/VTransition.vue' -import {pendingSubmission} from "~/composables/forms/pendingSubmission.js" +import { pendingSubmission } from "~/composables/forms/pendingSubmission.js" +import { usePartialSubmission } from "~/composables/forms/usePartialSubmission.js" import clonedeep from "clone-deep" import ThemeBuilder from "~/lib/forms/themes/ThemeBuilder.js" import FirstSubmissionModal from '~/components/open/forms/components/FirstSubmissionModal.vue' @@ -225,6 +226,7 @@ export default { authenticated: computed(() => authStore.check), isIframe: useIsIframe(), pendingSubmission: pendingSubmission(props.form), + partialSubmission: usePartialSubmission(props.form), confetti: useConfetti() } }, @@ -298,13 +300,18 @@ export default { if (form.busy) return this.loading = true + + if (this.form?.enable_partial_submissions) { + this.partialSubmission.stopSync() + } + form.post('/forms/' + this.form.slug + '/answer').then((data) => { this.submittedData = form.data() useAmplitude().logEvent('form_submission', { workspace_id: this.form.workspace_id, form_id: this.form.id }) - + const payload = clonedeep({ type: 'form-submitted', form: { @@ -342,6 +349,10 @@ export default { this.confetti.play() } }).catch((error) => { + if (this.form?.enable_partial_submissions) { + this.partialSubmission.startSync() + } + console.error(error) if (error.response && error.data) { useAlert().formValidationError(error.data) diff --git a/client/components/open/forms/OpenForm.vue b/client/components/open/forms/OpenForm.vue index 94415d50..9ef3eb2a 100644 --- a/client/components/open/forms/OpenForm.vue +++ b/client/components/open/forms/OpenForm.vue @@ -123,7 +123,8 @@ import draggable from 'vuedraggable' import OpenFormButton from './OpenFormButton.vue' import CaptchaInput from '~/components/forms/components/CaptchaInput.vue' import OpenFormField from './OpenFormField.vue' -import {pendingSubmission} from "~/composables/forms/pendingSubmission.js" +import { pendingSubmission } from "~/composables/forms/pendingSubmission.js" +import { usePartialSubmission } from "~/composables/forms/usePartialSubmission.js" import FormLogicPropertyResolver from "~/lib/forms/FormLogicPropertyResolver.js" import CachedDefaultTheme from "~/lib/forms/themes/CachedDefaultTheme.js" import FormTimer from './FormTimer.vue' @@ -191,6 +192,7 @@ export default { isIframe: useIsIframe(), draggingNewBlock: computed(() => workingFormStore.draggingNewBlock), pendingSubmission: import.meta.client ? pendingSubmission(props.form) : { get: () => ({}), set: () => {} }, + partialSubmission: import.meta.client ? usePartialSubmission(props.form, dataForm) : { startSync: () => {}, stopSync: () => {} }, formPageIndex: storeToRefs(workingFormStore).formPageIndex, // Used for admin previews @@ -203,6 +205,7 @@ export default { data() { return { isAutoSubmit: false, + partialSubmissionStarted: false, isInitialLoad: true, // Flag to prevent recursion in field group updates isUpdatingFieldGroups: false, @@ -354,10 +357,15 @@ export default { }, dataFormValue: { deep: true, - handler() { + handler(newValue, oldValue) { if (this.isPublicFormPage && this.form && this.form.auto_save) { this.pendingSubmission.set(this.dataFormValue) } + // Start partial submission sync on first form change + if (!this.adminPreview && this.form?.enable_partial_submissions && oldValue && Object.keys(oldValue).length > 0 && !this.partialSubmissionStarted) { + this.partialSubmission.startSync() + this.partialSubmissionStarted = true + } } }, @@ -391,34 +399,35 @@ export default { this.submitForm() } }, - + beforeUnmount() { + if (!this.adminPreview && this.form?.enable_partial_submissions) { + this.partialSubmission.stopSync() + } + }, methods: { async submitForm() { - this.dataForm.busy = true - try { - if (!await this.nextPage()) { - this.dataForm.busy = false - return - } - - if (!this.isAutoSubmit && this.formPageIndex !== this.fieldGroups.length - 1) { - this.dataForm.busy = false - return - } - - if (this.form.use_captcha && import.meta.client) { - this.$refs.captcha?.reset() + // Process payment if needed + if (!await this.doPayment()) { + return false // Payment failed or was required but not completed } + this.dataForm.busy = false + // Add submission_id for editable submissions (from main) if (this.form.editable_submissions && this.form.submission_id) { this.dataForm.submission_id = this.form.submission_id } + // Stop timer and get completion time (from main) this.$refs['form-timer'].stopTimer() this.dataForm.completion_time = this.$refs['form-timer'].completionTime - // Add validation strategy check + // Add submission hash for partial submissions (from HEAD) + if (this.form?.enable_partial_submissions) { + this.dataForm.submission_hash = this.partialSubmission.getSubmissionHash() + } + + // Add validation strategy check (from main) if (!this.formModeStrategy.validation.validateOnSubmit) { this.$emit('submit', this.dataForm, this.onSubmissionFailure) return @@ -427,6 +436,7 @@ export default { this.$emit('submit', this.dataForm, this.onSubmissionFailure) } catch (error) { this.handleValidationError(error) + } finally { this.dataForm.busy = false } }, @@ -666,13 +676,14 @@ export default { } }, async doPayment() { - // Use the stripeElements from setup instead of calling useStripeElements - const { state: stripeState, processPayment, isCardPopulated, isReadyForPayment } = this.stripeElements - // Check if there's a payment block in the current step if (!this.paymentBlock) { return true // No payment needed for this step } + this.dataForm.busy = true + + // Use the stripeElements from setup instead of calling useStripeElements + const { state: stripeState, processPayment, isCardPopulated, isReadyForPayment } = this.stripeElements // Skip if payment is already processed in the stripe state if (stripeState.intentId) { diff --git a/client/components/open/forms/components/FormStats.vue b/client/components/open/forms/components/FormStats.vue index 22498dbb..ba9b65f0 100644 --- a/client/components/open/forms/components/FormStats.vue +++ b/client/components/open/forms/components/FormStats.vue @@ -118,7 +118,12 @@ export default { borderColor: "rgba(16, 185, 129, 1)", data: [], }, - ], + ].concat(this.form.enable_partial_submissions ? [{ + label: "Partial Submissions", + backgroundColor: "rgba(255, 193, 7, 1)", + borderColor: "rgba(255, 193, 7, 1)", + data: [], + }] : []), }, chartOptions: { scales: { @@ -172,6 +177,9 @@ export default { this.chartData.labels = Object.keys(statsData.views) this.chartData.datasets[0].data = statsData.views this.chartData.datasets[1].data = statsData.submissions + if (this.form.enable_partial_submissions) { + this.chartData.datasets[2].data = statsData.partial_submissions + } this.isLoading = false } }).catch((error) => { diff --git a/client/components/open/forms/components/FormSubmissions.vue b/client/components/open/forms/components/FormSubmissions.vue index 0ce4ee01..1af1a1e4 100644 --- a/client/components/open/forms/components/FormSubmissions.vue +++ b/client/components/open/forms/components/FormSubmissions.vue @@ -42,6 +42,14 @@
+ submission.status === this.selectedStatus) + } if (this.searchForm.search === '' || this.searchForm.search === null) { return filteredData @@ -170,6 +188,9 @@ export default { }, 'searchForm.search'() { this.dataChanged() + }, + 'selectedStatus'() { + this.dataChanged() } }, diff --git a/client/components/open/forms/components/form-components/FormSubmissionSettings.vue b/client/components/open/forms/components/form-components/FormSubmissionSettings.vue index 8f64f9bc..d78cf81b 100644 --- a/client/components/open/forms/components/form-components/FormSubmissionSettings.vue +++ b/client/components/open/forms/components/form-components/FormSubmissionSettings.vue @@ -31,7 +31,7 @@ v-if="hasPaymentBlock" color="primary" variant="subtle" - title="You have a payment block in your form. so can't disable auto save" + title="Must be enabled with a payment block." class="max-w-md" /> @@ -76,6 +76,31 @@
+ +

+ Advanced Submission Options +

+

+ Configure advanced options for form submissions and data collection. +

+ + + + +

After Submission + +

+ Status +

+ + + + new Date(b.created_at) - new Date(a.created_at)) } diff --git a/client/composables/forms/pendingSubmission.js b/client/composables/forms/pendingSubmission.js index 0a7ffc95..b4510327 100644 --- a/client/composables/forms/pendingSubmission.js +++ b/client/composables/forms/pendingSubmission.js @@ -31,6 +31,17 @@ export const pendingSubmission = (form) => { return pendingSubmission ? JSON.parse(pendingSubmission) : defaultValue } + const setSubmissionHash = (hash) => { + set({ + ...get(), + submission_hash: hash + }) + } + + const getSubmissionHash = () => { + return get()?.submission_hash ?? null + } + const setTimer = (value) => { if (import.meta.server) return useStorage(formPendingSubmissionTimerKey.value).value = value @@ -46,10 +57,13 @@ export const pendingSubmission = (form) => { } return { + formPendingSubmissionKey, enabled, set, get, remove, + setSubmissionHash, + getSubmissionHash, setTimer, removeTimer, getTimer, diff --git a/client/composables/forms/usePartialSubmission.js b/client/composables/forms/usePartialSubmission.js new file mode 100644 index 00000000..2e36d596 --- /dev/null +++ b/client/composables/forms/usePartialSubmission.js @@ -0,0 +1,131 @@ +import { opnFetch } from "./../useOpnApi.js" +import { pendingSubmission as pendingSubmissionFunction } from "./pendingSubmission.js" +import { watch, onBeforeUnmount, ref } from 'vue' + +// Create a Map to store submission hashes for different forms +const submissionHashes = ref(new Map()) + +export const usePartialSubmission = (form, formData = {}) => { + const pendingSubmission = pendingSubmissionFunction(form) + + let syncTimeout = null + let dataWatcher = null + + const getSubmissionHash = () => { + return pendingSubmission.getSubmissionHash() ?? submissionHashes.value.get(pendingSubmission.formPendingSubmissionKey.value) + } + + const setSubmissionHash = (hash) => { + submissionHashes.value.set(pendingSubmission.formPendingSubmissionKey.value, hash) + pendingSubmission.setSubmissionHash(hash) + } + + const debouncedSync = () => { + if (syncTimeout) clearTimeout(syncTimeout) + syncTimeout = setTimeout(() => { + syncToServer() + }, 1000) // 1 second debounce + } + + const syncToServer = async () => { + // Check if partial submissions are enabled and if we have data + if (!form?.enable_partial_submissions) return + + // Get current form data - handle both function and direct object patterns + const currentData = typeof formData.value?.data === 'function' + ? formData.value.data() + : formData.value + + // Skip if no data or empty data + if (!currentData || Object.keys(currentData).length === 0) return + + try { + const response = await opnFetch(`/forms/${form.slug}/answer`, { + method: "POST", + body: { + ...currentData, + 'is_partial': true, + 'submission_hash': getSubmissionHash() + } + }) + if (response.submission_hash) { + setSubmissionHash(response.submission_hash) + } + } catch (error) { + console.error('Failed to sync partial submission', error) + } + } + + // Add these handlers as named functions so we can remove them later + const handleVisibilityChange = () => { + if (document.visibilityState === 'hidden') { + debouncedSync() + } + } + + const handleBlur = () => { + debouncedSync() + } + + const handleBeforeUnload = () => { + syncToServer() + } + + const startSync = () => { + if (dataWatcher) return + + // Initial sync + debouncedSync() + + // Watch formData directly with Vue's reactivity + dataWatcher = watch( + formData, + () => { + debouncedSync() + }, + { deep: true } + ) + + // Add event listeners for critical moments + document.addEventListener('visibilitychange', handleVisibilityChange) + window.addEventListener('blur', handleBlur) + window.addEventListener('beforeunload', handleBeforeUnload) + } + + const stopSync = () => { + submissionHashes.value = new Map() + + if (dataWatcher) { + dataWatcher() + dataWatcher = null + } + + if (syncTimeout) { + clearTimeout(syncTimeout) + syncTimeout = null + } + + // Remove event listeners + document.removeEventListener('visibilitychange', handleVisibilityChange) + window.removeEventListener('blur', handleBlur) + window.removeEventListener('beforeunload', handleBeforeUnload) + } + + // Ensure cleanup when component is unmounted + onBeforeUnmount(() => { + stopSync() + + // Final sync attempt before unmounting + if(getSubmissionHash()) { + syncToServer() + } + }) + + return { + startSync, + stopSync, + syncToServer, + getSubmissionHash, + setSubmissionHash + } +} \ No newline at end of file diff --git a/client/pages/settings/connections/callback/[service].vue b/client/pages/settings/connections/callback/[service].vue index da16868b..e92e3d40 100644 --- a/client/pages/settings/connections/callback/[service].vue +++ b/client/pages/settings/connections/callback/[service].vue @@ -63,16 +63,10 @@ async function handleCallback() { // Get autoClose preference from the API response data const shouldAutoClose = data?.autoClose === true - console.log('[CallbackPage] Checking autoClose status from API data:', { - apiValue: data?.autoClose, - shouldAutoClose - }) - alert.success('Account connected successfully.') // Close window if autoClose is set from API data, otherwise redirect if (shouldAutoClose) { - console.log('[CallbackPage] Attempting window.close() based on API data.') window.close() // Add a fallback check in case window.close is blocked setTimeout(() => { @@ -83,7 +77,6 @@ async function handleCallback() { } }, 500) // Check after 500ms } else { - console.log('[CallbackPage] autoClose is false or not detected in API data, redirecting.') router.push('/settings/connections') }