From d0f73cf82d2afd15a838bd5056ba20abb8bac78d Mon Sep 17 00:00:00 2001 From: Julien Nahum Date: Thu, 6 Mar 2025 13:12:17 +0800 Subject: [PATCH] Refactor partial form submission sync mechanism - Improve partial submission synchronization in usePartialSubmission composable - Replace interval-based sync with Vue's reactive watch - Add robust handling for different form data input patterns - Implement onBeforeUnmount hook for final sync attempt - Enhance data synchronization reliability and performance --- .../Forms/PublicFormController.php | 18 +++---- api/app/Jobs/Form/StoreFormSubmissionJob.php | 25 ++++++++-- .../composables/forms/usePartialSubmission.js | 48 ++++++++++++++----- 3 files changed, 65 insertions(+), 26 deletions(-) diff --git a/api/app/Http/Controllers/Forms/PublicFormController.php b/api/app/Http/Controllers/Forms/PublicFormController.php index a6c30cfa0..606b1fd7e 100644 --- a/api/app/Http/Controllers/Forms/PublicFormController.php +++ b/api/app/Http/Controllers/Forms/PublicFormController.php @@ -94,19 +94,19 @@ public function showAsset($assetFileName) private function handlePartialSubmissions(Request $request) { $form = $request->form; - $submissionId = null; // Process submission data to extract submission ID $submissionData = $this->processSubmissionIdentifiers($request, $request->all()); - $submissionId = $submissionData['submission_id'] ?? null; - $submissionResponse = $form->submissions()->updateOrCreate([ - 'id' => $submissionId - ], [ - 'data' => $submissionData, - 'status' => FormSubmission::STATUS_PARTIAL - ]); - $submissionId = $submissionResponse->id; + // 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', diff --git a/api/app/Jobs/Form/StoreFormSubmissionJob.php b/api/app/Jobs/Form/StoreFormSubmissionJob.php index 64e22582c..6293c9c0d 100644 --- a/api/app/Jobs/Form/StoreFormSubmissionJob.php +++ b/api/app/Jobs/Form/StoreFormSubmissionJob.php @@ -28,9 +28,15 @@ * 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 + * - 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 { @@ -42,6 +48,7 @@ class StoreFormSubmissionJob implements ShouldQueue public ?int $submissionId = null; private ?array $formData = null; private ?int $completionTime = null; + private bool $isPartial = false; /** * Create a new job instance. @@ -72,7 +79,10 @@ public function handle() // 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); + } } /** @@ -81,6 +91,7 @@ public function handle() * This method extracts and removes metadata fields from the submission data: * - submission_id * - completion_time + * - is_partial */ private function extractMetadata(): void { @@ -98,8 +109,9 @@ private function extractMetadata(): void unset($this->submissionData['submission_id']); } - // Remove is_partial flag if present + // 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']); } } @@ -133,7 +145,12 @@ private function storeSubmission(array $formData) $submission->data = $formData; $submission->completion_time = $this->completionTime; - $submission->status = FormSubmission::STATUS_COMPLETED; + + // 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 diff --git a/client/composables/forms/usePartialSubmission.js b/client/composables/forms/usePartialSubmission.js index 0161a9e3a..31dfc21c1 100644 --- a/client/composables/forms/usePartialSubmission.js +++ b/client/composables/forms/usePartialSubmission.js @@ -1,5 +1,6 @@ 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()) @@ -7,9 +8,8 @@ const submissionHashes = ref(new Map()) export const usePartialSubmission = (form, formData = {}) => { const pendingSubmission = pendingSubmissionFunction(form) - const SYNC_INTERVAL = 30000 // 30 seconds - let syncInterval = null let syncTimeout = null + let dataWatcher = null const getSubmissionHash = () => { return submissionHashes.value.get(pendingSubmission.formPendingSubmissionKey.value) @@ -27,13 +27,22 @@ export const usePartialSubmission = (form, formData = {}) => { } const syncToServer = async () => { - if (!form?.enable_partial_submissions || !formData.value.data() || Object.keys(formData.value.data()).length === 0) return + // 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: { - ...formData.value.data(), + ...currentData, 'is_partial': true, 'submission_hash': getSubmissionHash() } @@ -62,27 +71,32 @@ export const usePartialSubmission = (form, formData = {}) => { } const startSync = () => { - if (syncInterval) return + if (dataWatcher) return // Initial sync debouncedSync() - // Regular interval sync - syncInterval = setInterval(() => { - debouncedSync() - }, SYNC_INTERVAL) + // Watch formData directly with Vue's reactivity + dataWatcher = watch( + formData, + () => { + debouncedSync() + }, + { deep: true } + ) - // Add event listeners + // Add event listeners for critical moments document.addEventListener('visibilitychange', handleVisibilityChange) window.addEventListener('blur', handleBlur) window.addEventListener('beforeunload', handleBeforeUnload) } const stopSync = () => { - if (syncInterval) { - clearInterval(syncInterval) - syncInterval = null + if (dataWatcher) { + dataWatcher() + dataWatcher = null } + if (syncTimeout) { clearTimeout(syncTimeout) syncTimeout = null @@ -94,6 +108,14 @@ export const usePartialSubmission = (form, formData = {}) => { window.removeEventListener('beforeunload', handleBeforeUnload) } + // Ensure cleanup when component is unmounted + onBeforeUnmount(() => { + stopSync() + + // Final sync attempt before unmounting + syncToServer() + }) + return { startSync, stopSync,