From c3d05960e8077e80dcf2819c894b78e47673f2f6 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Tue, 18 Feb 2025 22:02:30 +0530 Subject: [PATCH 01/21] Implement partial form submissions feature --- .../Forms/PublicFormController.php | 20 +++++ api/app/Http/Requests/AnswerFormRequest.php | 5 ++ api/app/Http/Requests/UserFormRequest.php | 1 + .../Http/Resources/FormSubmissionResource.php | 1 + api/app/Models/Forms/Form.php | 4 +- api/app/Models/Forms/FormSubmission.php | 4 + ...artial_submissions_to_form_submissions.php | 40 +++++++++ client/components/open/forms/OpenForm.vue | 13 ++- .../FormSubmissionSettings.vue | 8 ++ client/components/open/tables/OpenTable.vue | 23 +++++ client/composables/forms/pendingSubmission.js | 1 + .../composables/forms/usePartialSubmission.js | 86 +++++++++++++++++++ client/stores/working_form.js | 9 +- 13 files changed, 211 insertions(+), 4 deletions(-) create mode 100644 api/database/migrations/2025_02_14_073642_add_partial_submissions_to_form_submissions.php create mode 100644 client/composables/forms/usePartialSubmission.js diff --git a/api/app/Http/Controllers/Forms/PublicFormController.php b/api/app/Http/Controllers/Forms/PublicFormController.php index d1669c03f..17d3971ae 100644 --- a/api/app/Http/Controllers/Forms/PublicFormController.php +++ b/api/app/Http/Controllers/Forms/PublicFormController.php @@ -91,6 +91,26 @@ public function answer(AnswerFormRequest $request, FormSubmissionProcessor $form $isFirstSubmission = ($form->submissions_count === 0); $submissionId = false; + $isPartial = $request->get('is-partial') ?? false; + if ($isPartial) { + $hash = $request->get('submission-hash') ?? null; + if ($hash) { + $hash = Hashids::decode($hash); + $hash = $hash[0] ?? null; + } + $submissionResponse = $form->submissions()->updateOrCreate([ + 'id' => $hash + ], [ + 'data' => $request->all(), + 'status' => FormSubmission::STATUS_PARTIAL + ]); + + return $this->success([ + 'message' => 'Partial submission saved', + 'submission_hash' => Hashids::encode($submissionResponse->id) + ]); + } + $submissionData = $request->validated(); $completionTime = $request->get('completion_time') ?? null; unset($submissionData['completion_time']); // Remove completion_time from the main data array diff --git a/api/app/Http/Requests/AnswerFormRequest.php b/api/app/Http/Requests/AnswerFormRequest.php index 6431bbef5..b11efe986 100644 --- a/api/app/Http/Requests/AnswerFormRequest.php +++ b/api/app/Http/Requests/AnswerFormRequest.php @@ -54,6 +54,11 @@ public function authorize() */ 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 1ed971c24..9e374115b 100644 --- a/api/app/Http/Requests/UserFormRequest.php +++ b/api/app/Http/Requests/UserFormRequest.php @@ -81,6 +81,7 @@ public function rules() '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 4572db0fa..02825216d 100644 --- a/api/app/Http/Resources/FormSubmissionResource.php +++ b/api/app/Http/Resources/FormSubmissionResource.php @@ -40,6 +40,7 @@ public function publiclyAccessed($publiclyAccessed = true) 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/Models/Forms/Form.php b/api/app/Models/Forms/Form.php index aae7b69a8..433aa1f5a 100644 --- a/api/app/Models/Forms/Form.php +++ b/api/app/Models/Forms/Form.php @@ -93,6 +93,7 @@ class Form extends Model implements CachableAttributes 'show_progress_bar', 'auto_save', 'auto_focus', + 'enable_partial_submissions', // Security & Privacy 'can_be_indexed', @@ -110,7 +111,8 @@ protected function casts(): array 'closes_at' => 'datetime', 'tags' => 'array', 'removed_properties' => 'array', - 'seo_meta' => 'object' + 'seo_meta' => 'object', + 'enable_partial_submissions' => 'boolean', ]; } diff --git a/api/app/Models/Forms/FormSubmission.php b/api/app/Models/Forms/FormSubmission.php index 003a9caa2..57613560e 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/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 000000000..1780dec33 --- /dev/null +++ b/api/database/migrations/2025_02_14_073642_add_partial_submissions_to_form_submissions.php @@ -0,0 +1,40 @@ +enum('status', ['partial', 'completed']) + ->default('completed') + ->after('form_id') + ->index(); + }); + + Schema::table('forms', function (Blueprint $table) { + $table->boolean('enable_partial_submissions') + ->default(false) + ->after('auto_save'); + }); + } + + /** + * 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/client/components/open/forms/OpenForm.vue b/client/components/open/forms/OpenForm.vue index 29121ed7f..6afe25d8e 100644 --- a/client/components/open/forms/OpenForm.vue +++ b/client/components/open/forms/OpenForm.vue @@ -129,7 +129,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 {computed} from "vue" import CachedDefaultTheme from "~/lib/forms/themes/CachedDefaultTheme.js" @@ -186,6 +187,7 @@ export default { isIframe: useIsIframe(), draggingNewBlock: computed(() => workingFormStore.draggingNewBlock), pendingSubmission: pendingSubmission(props.form), + partialSubmission: usePartialSubmission(props.form, dataForm), formPageIndex: storeToRefs(workingFormStore).formPageIndex, // Used for admin previews @@ -356,8 +358,15 @@ export default { this.isAutoSubmit = true this.submitForm() } + if (!this.adminPreview && this.form?.enable_partial_submissions) { + this.partialSubmission.startSync() + } + }, + beforeUnmount() { + if (!this.adminPreview && this.form?.enable_partial_submissions) { + this.partialSubmission.stopSync() + } }, - methods: { submitForm() { if (!this.isAutoSubmit && this.formPageIndex !== this.fieldGroups.length - 1) return diff --git a/client/components/open/forms/components/form-components/FormSubmissionSettings.vue b/client/components/open/forms/components/form-components/FormSubmissionSettings.vue index fb945102d..a39d93d7a 100644 --- a/client/components/open/forms/components/form-components/FormSubmissionSettings.vue +++ b/client/components/open/forms/components/form-components/FormSubmissionSettings.vue @@ -25,6 +25,14 @@ label="Auto save form response" help="Saves form progress, allowing respondents to resume later." /> + + + +

+ Status +

+ + +
+ {{ row.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 0a7ffc95a..b321e75de 100644 --- a/client/composables/forms/pendingSubmission.js +++ b/client/composables/forms/pendingSubmission.js @@ -46,6 +46,7 @@ export const pendingSubmission = (form) => { } return { + formPendingSubmissionKey, enabled, set, get, diff --git a/client/composables/forms/usePartialSubmission.js b/client/composables/forms/usePartialSubmission.js new file mode 100644 index 000000000..21d4d6a59 --- /dev/null +++ b/client/composables/forms/usePartialSubmission.js @@ -0,0 +1,86 @@ +import { opnFetch } from "./../useOpnApi.js" +import { pendingSubmission as pendingSubmissionFunction } from "./pendingSubmission.js" +import { useWorkingFormStore } from "~/stores/working_form.js" + +export const usePartialSubmission = (form, formData) => { + const pendingSubmission = pendingSubmissionFunction(form) + const workingFormStore = useWorkingFormStore() + + const SYNC_INTERVAL = 30000 // 30 seconds + let syncInterval = null + let syncTimeout = null + + const debouncedSync = () => { + if (syncTimeout) clearTimeout(syncTimeout) + syncTimeout = setTimeout(() => { + syncToServer() + }, 1000) // 1 second debounce + } + + const syncToServer = async () => { + if (!form?.enable_partial_submissions || !formData.value.data() || Object.keys(formData.value.data()).length === 0) return + + try { + const submissionHash = workingFormStore.getSubmissionHash(pendingSubmission.formPendingSubmissionKey.value) + const response = await opnFetch(`/forms/${form.slug}/answer`, { + method: "POST", + body: { + ...formData.value.data(), + 'is-partial': true, + 'submission-hash': submissionHash + } + }) + if (response.submission_hash) { + workingFormStore.setSubmissionHash(pendingSubmission.formPendingSubmissionKey.value, response.submission_hash) + } + } catch (error) { + console.error('Failed to sync partial submission', error) + } + } + + const startSync = () => { + if (syncInterval) return + + // Initial sync + debouncedSync() + + // Regular interval sync + syncInterval = setInterval(() => { + debouncedSync() + }, SYNC_INTERVAL) + + // Sync on visibility/focus changes + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + debouncedSync() + } + }) + + window.addEventListener('blur', () => { + debouncedSync() + }) + + // Sync before page unload + window.addEventListener('beforeunload', () => { + // For beforeunload, we want to sync immediately without debounce + syncToServer() + }) + } + + const stopSync = () => { + if (syncInterval) { + clearInterval(syncInterval) + syncInterval = null + } + if (syncTimeout) { + clearTimeout(syncTimeout) + syncTimeout = null + } + } + + return { + startSync, + stopSync, + syncToServer + } +} \ No newline at end of file diff --git a/client/stores/working_form.js b/client/stores/working_form.js index 59f811a95..92f2db15c 100644 --- a/client/stores/working_form.js +++ b/client/stores/working_form.js @@ -9,6 +9,7 @@ export const useWorkingFormStore = defineStore("working_form", { content: null, activeTab: 0, formPageIndex: 0, + submissionHash: {}, // Field being edited selectedFieldIndex: null, @@ -165,7 +166,13 @@ export const useWorkingFormStore = defineStore("working_form", { const field = newFields.splice(oldIndex, 1)[0] newFields.splice(newIndex, 0, field) this.content.properties = newFields - } + }, + setSubmissionHash(key, hash) { + this.submissionHash[key] = hash + }, + getSubmissionHash(key) { + return this.submissionHash[key] ?? null + }, }, history: {} }) From a0c5b922cc5955c163847d42f46dc645d7660a53 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Wed, 19 Feb 2025 10:06:57 +0530 Subject: [PATCH 02/21] Add status filtering for form submissions --- .../open/forms/components/FormSubmissions.vue | 22 ++++++++++++++++++- client/components/open/tables/OpenTable.vue | 8 ++++--- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/client/components/open/forms/components/FormSubmissions.vue b/client/components/open/forms/components/FormSubmissions.vue index 0ce4ee01d..1e409bacd 100644 --- a/client/components/open/forms/components/FormSubmissions.vue +++ b/client/components/open/forms/components/FormSubmissions.vue @@ -42,6 +42,13 @@
+ submission.status === this.selectedStatus) + } if (this.searchForm.search === '' || this.searchForm.search === null) { return filteredData @@ -170,6 +187,9 @@ export default { }, 'searchForm.search'() { this.dataChanged() + }, + 'selectedStatus'() { + this.dataChanged() } }, diff --git a/client/components/open/tables/OpenTable.vue b/client/components/open/tables/OpenTable.vue index 899350ff5..48df5b7c7 100644 --- a/client/components/open/tables/OpenTable.vue +++ b/client/components/open/tables/OpenTable.vue @@ -102,9 +102,11 @@ class="n-table-cell border-gray-100 dark:border-gray-900 text-sm p-2 border-b" style="width: 100px" > -
- {{ row.status }} -
+ Date: Thu, 20 Feb 2025 09:41:38 +0530 Subject: [PATCH 03/21] Add Partial Submission in Analytics --- api/app/Http/Controllers/Forms/FormStatsController.php | 6 ++++-- api/app/Models/Forms/Form.php | 2 +- client/components/open/forms/components/FormStats.vue | 7 +++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/api/app/Http/Controllers/Forms/FormStatsController.php b/api/app/Http/Controllers/Forms/FormStatsController.php index e24a25066..eb6fedc60 100644 --- a/api/app/Http/Controllers/Forms/FormStatsController.php +++ b/api/app/Http/Controllers/Forms/FormStatsController.php @@ -4,6 +4,7 @@ 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 @@ public function getFormStats(FormStatsRequest $request) $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/Models/Forms/Form.php b/api/app/Models/Forms/Form.php index 433aa1f5a..6d0409640 100644 --- a/api/app/Models/Forms/Form.php +++ b/api/app/Models/Forms/Form.php @@ -176,7 +176,7 @@ public function getEditUrlAttribute() public function getSubmissionsCountAttribute() { - return $this->submissions()->count(); + return $this->submissions()->where('status', FormSubmission::STATUS_COMPLETED)->count(); } public function getViewsCountAttribute() diff --git a/client/components/open/forms/components/FormStats.vue b/client/components/open/forms/components/FormStats.vue index 22498dbb9..772648f46 100644 --- a/client/components/open/forms/components/FormStats.vue +++ b/client/components/open/forms/components/FormStats.vue @@ -118,6 +118,12 @@ export default { borderColor: "rgba(16, 185, 129, 1)", data: [], }, + { + label: "Partial Submissions", + backgroundColor: "rgba(255, 193, 7, 1)", + borderColor: "rgba(255, 193, 7, 1)", + data: [], + }, ], }, chartOptions: { @@ -172,6 +178,7 @@ export default { this.chartData.labels = Object.keys(statsData.views) this.chartData.datasets[0].data = statsData.views this.chartData.datasets[1].data = statsData.submissions + this.chartData.datasets[2].data = statsData.partial_submissions this.isLoading = false } }).catch((error) => { From 00053cd761e2f61dd19287c2b9de30503478c047 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Thu, 20 Feb 2025 10:17:35 +0530 Subject: [PATCH 04/21] improve partial submission --- .../Forms/PublicFormController.php | 26 +++++++---- api/app/Http/Requests/AnswerFormRequest.php | 2 +- api/app/Jobs/Form/StoreFormSubmissionJob.php | 11 +++-- .../open/forms/OpenCompleteForm.vue | 8 +++- client/components/open/forms/OpenForm.vue | 3 ++ .../composables/forms/usePartialSubmission.js | 46 +++++++++++-------- 6 files changed, 63 insertions(+), 33 deletions(-) diff --git a/api/app/Http/Controllers/Forms/PublicFormController.php b/api/app/Http/Controllers/Forms/PublicFormController.php index 17d3971ae..e7b93741e 100644 --- a/api/app/Http/Controllers/Forms/PublicFormController.php +++ b/api/app/Http/Controllers/Forms/PublicFormController.php @@ -90,30 +90,38 @@ public function answer(AnswerFormRequest $request, FormSubmissionProcessor $form $form = $request->form; $isFirstSubmission = ($form->submissions_count === 0); $submissionId = false; + $submissionHash = $request->get('submission_hash') ?? null; + if ($submissionHash) { + $submissionHash = Hashids::decode($submissionHash); + $submissionId = (int)($submissionHash[0] ?? null); + } - $isPartial = $request->get('is-partial') ?? false; + $isPartial = $request->get('is_partial') ?? false; if ($isPartial) { - $hash = $request->get('submission-hash') ?? null; - if ($hash) { - $hash = Hashids::decode($hash); - $hash = $hash[0] ?? null; - } $submissionResponse = $form->submissions()->updateOrCreate([ - 'id' => $hash + 'id' => $submissionId ], [ 'data' => $request->all(), 'status' => FormSubmission::STATUS_PARTIAL ]); + $submissionId = $submissionResponse->id; return $this->success([ 'message' => 'Partial submission saved', - 'submission_hash' => Hashids::encode($submissionResponse->id) + 'submission_hash' => Hashids::encode($submissionId) ]); } $submissionData = $request->validated(); $completionTime = $request->get('completion_time') ?? null; - unset($submissionData['completion_time']); // Remove completion_time from the main data array + // Remove extra fields from the main data array + unset($submissionData['completion_time']); + unset($submissionData['submission_hash']); + + // Add submission_id to the submission data if it exists + if ($submissionId) { + $submissionData['submission_id'] = $submissionId; + } $job = new StoreFormSubmissionJob($form, $submissionData, $completionTime); diff --git a/api/app/Http/Requests/AnswerFormRequest.php b/api/app/Http/Requests/AnswerFormRequest.php index b11efe986..7baa9d8c8 100644 --- a/api/app/Http/Requests/AnswerFormRequest.php +++ b/api/app/Http/Requests/AnswerFormRequest.php @@ -55,7 +55,7 @@ public function authorize() public function rules() { // Skip validation if this is a partial submission - if ($this->has('is-partial')) { + if ($this->has('is_partial')) { return []; } diff --git a/api/app/Jobs/Form/StoreFormSubmissionJob.php b/api/app/Jobs/Form/StoreFormSubmissionJob.php index d5dd82147..9f54050b0 100644 --- a/api/app/Jobs/Form/StoreFormSubmissionJob.php +++ b/api/app/Jobs/Form/StoreFormSubmissionJob.php @@ -34,9 +34,7 @@ class StoreFormSubmissionJob implements ShouldQueue * * @return void */ - public function __construct(public Form $form, public array $submissionData, public ?int $completionTime = null) - { - } + public function __construct(public Form $form, public array $submissionData, public ?int $completionTime = null) {} /** * Execute the job. @@ -68,16 +66,23 @@ public function setSubmissionId(int $id) private function storeSubmission(array $formData) { + // If submission_id is set, use it + if (isset($this->submissionData['submission_id']) && $this->submissionData['submission_id']) { + $this->submissionId = $this->submissionData['submission_id']; + } + // Create or update record if ($previousSubmission = $this->submissionToUpdate()) { $previousSubmission->data = $formData; $previousSubmission->completion_time = $this->completionTime; + $previousSubmission->status = FormSubmission::STATUS_COMPLETED; $previousSubmission->save(); $this->submissionId = $previousSubmission->id; } else { $response = $this->form->submissions()->create([ 'data' => $formData, 'completion_time' => $this->completionTime, + 'status' => FormSubmission::STATUS_COMPLETED, ]); $this->submissionId = $response->id; } diff --git a/client/components/open/forms/OpenCompleteForm.vue b/client/components/open/forms/OpenCompleteForm.vue index d8f87d684..4bc3dcdda 100644 --- a/client/components/open/forms/OpenCompleteForm.vue +++ b/client/components/open/forms/OpenCompleteForm.vue @@ -204,7 +204,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' @@ -235,6 +236,7 @@ export default { authenticated: computed(() => authStore.check), isIframe: useIsIframe(), pendingSubmission: pendingSubmission(props.form), + partialSubmission: usePartialSubmission(props.form), confetti: useConfetti() } }, @@ -308,6 +310,10 @@ export default { form_id: this.form.id }) + if (this.form?.enable_partial_submissions) { + this.partialSubmission.stopSync() + } + const payload = clonedeep({ type: 'form-submitted', form: { diff --git a/client/components/open/forms/OpenForm.vue b/client/components/open/forms/OpenForm.vue index 6afe25d8e..f9aeef9ed 100644 --- a/client/components/open/forms/OpenForm.vue +++ b/client/components/open/forms/OpenForm.vue @@ -381,6 +381,9 @@ export default { this.$refs['form-timer'].stopTimer() this.dataForm.completion_time = this.$refs['form-timer'].completionTime + if (this.form?.enable_partial_submissions) { + this.dataForm.submission_hash = this.workingFormStore.getSubmissionHash(this.pendingSubmission.formPendingSubmissionKey.value) + } this.$emit('submit', this.dataForm, this.onSubmissionFailure) }, diff --git a/client/composables/forms/usePartialSubmission.js b/client/composables/forms/usePartialSubmission.js index 21d4d6a59..cd62da88f 100644 --- a/client/composables/forms/usePartialSubmission.js +++ b/client/composables/forms/usePartialSubmission.js @@ -2,7 +2,7 @@ import { opnFetch } from "./../useOpnApi.js" import { pendingSubmission as pendingSubmissionFunction } from "./pendingSubmission.js" import { useWorkingFormStore } from "~/stores/working_form.js" -export const usePartialSubmission = (form, formData) => { +export const usePartialSubmission = (form, formData = {}) => { const pendingSubmission = pendingSubmissionFunction(form) const workingFormStore = useWorkingFormStore() @@ -26,8 +26,8 @@ export const usePartialSubmission = (form, formData) => { method: "POST", body: { ...formData.value.data(), - 'is-partial': true, - 'submission-hash': submissionHash + 'is_partial': true, + 'submission_hash': submissionHash } }) if (response.submission_hash) { @@ -38,6 +38,21 @@ export const usePartialSubmission = (form, formData) => { } } + // 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 (syncInterval) return @@ -49,22 +64,10 @@ export const usePartialSubmission = (form, formData) => { debouncedSync() }, SYNC_INTERVAL) - // Sync on visibility/focus changes - document.addEventListener('visibilitychange', () => { - if (document.visibilityState === 'hidden') { - debouncedSync() - } - }) - - window.addEventListener('blur', () => { - debouncedSync() - }) - - // Sync before page unload - window.addEventListener('beforeunload', () => { - // For beforeunload, we want to sync immediately without debounce - syncToServer() - }) + // Add event listeners + document.addEventListener('visibilitychange', handleVisibilityChange) + window.addEventListener('blur', handleBlur) + window.addEventListener('beforeunload', handleBeforeUnload) } const stopSync = () => { @@ -76,6 +79,11 @@ export const usePartialSubmission = (form, formData) => { clearTimeout(syncTimeout) syncTimeout = null } + + // Remove event listeners + document.removeEventListener('visibilitychange', handleVisibilityChange) + window.removeEventListener('blur', handleBlur) + window.removeEventListener('beforeunload', handleBeforeUnload) } return { From 1a96a8482bc8bd500e6b44eb526afa9d2e77655a Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Thu, 20 Feb 2025 10:21:52 +0530 Subject: [PATCH 05/21] fix lint --- api/app/Jobs/Form/StoreFormSubmissionJob.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/app/Jobs/Form/StoreFormSubmissionJob.php b/api/app/Jobs/Form/StoreFormSubmissionJob.php index 9f54050b0..e8b87d417 100644 --- a/api/app/Jobs/Form/StoreFormSubmissionJob.php +++ b/api/app/Jobs/Form/StoreFormSubmissionJob.php @@ -34,7 +34,9 @@ class StoreFormSubmissionJob implements ShouldQueue * * @return void */ - public function __construct(public Form $form, public array $submissionData, public ?int $completionTime = null) {} + public function __construct(public Form $form, public array $submissionData, public ?int $completionTime = null) + { + } /** * Execute the job. From fb010a6a89a32cd606b78444e47a873174321b27 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Thu, 20 Feb 2025 10:30:12 +0530 Subject: [PATCH 06/21] Add type checking for submission ID in form submission job --- api/app/Jobs/Form/StoreFormSubmissionJob.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/app/Jobs/Form/StoreFormSubmissionJob.php b/api/app/Jobs/Form/StoreFormSubmissionJob.php index e8b87d417..9c760833a 100644 --- a/api/app/Jobs/Form/StoreFormSubmissionJob.php +++ b/api/app/Jobs/Form/StoreFormSubmissionJob.php @@ -69,7 +69,7 @@ public function setSubmissionId(int $id) private function storeSubmission(array $formData) { // If submission_id is set, use it - if (isset($this->submissionData['submission_id']) && $this->submissionData['submission_id']) { + if (isset($this->submissionData['submission_id']) && $this->submissionData['submission_id'] && is_int($this->submissionData['submission_id'])) { $this->submissionId = $this->submissionData['submission_id']; } @@ -95,7 +95,7 @@ private function storeSubmission(array $formData) */ private function submissionToUpdate(): ?FormSubmission { - if ($this->submissionId) { + if ($this->submissionId && is_int($this->submissionId)) { return $this->form->submissions()->findOrFail($this->submissionId); } if ($this->form->editable_submissions && isset($this->submissionData['submission_id']) && $this->submissionData['submission_id']) { From e4d0f13d649ca976a50c629122330cc7547ea9e9 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Tue, 25 Feb 2025 12:32:25 +0530 Subject: [PATCH 07/21] on form stats Partial Submissions only if enable --- client/components/open/forms/components/FormStats.vue | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/client/components/open/forms/components/FormStats.vue b/client/components/open/forms/components/FormStats.vue index 772648f46..ba9b65f05 100644 --- a/client/components/open/forms/components/FormStats.vue +++ b/client/components/open/forms/components/FormStats.vue @@ -118,13 +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: { @@ -178,7 +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 - this.chartData.datasets[2].data = statsData.partial_submissions + if (this.form.enable_partial_submissions) { + this.chartData.datasets[2].data = statsData.partial_submissions + } this.isLoading = false } }).catch((error) => { From 33c1c86ee892b065ee46a25ca9bcada63746f545 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Tue, 25 Feb 2025 12:34:45 +0530 Subject: [PATCH 08/21] Partial Submissions is PRO Feature --- api/app/Service/Forms/FormCleaner.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/app/Service/Forms/FormCleaner.php b/api/app/Service/Forms/FormCleaner.php index 91878f3ab..7670b005a 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.', From 2b5c0fe2d46daa473f9a2fd25038cfe67c23b51b Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Tue, 25 Feb 2025 15:33:33 +0530 Subject: [PATCH 09/21] Partial Submissions is PRO Feature --- .../Controllers/Forms/PublicFormController.php | 2 +- .../form-components/FormSubmissionSettings.vue | 15 ++++++++++++--- client/components/open/tables/OpenTable.vue | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/api/app/Http/Controllers/Forms/PublicFormController.php b/api/app/Http/Controllers/Forms/PublicFormController.php index e7b93741e..bbbee5531 100644 --- a/api/app/Http/Controllers/Forms/PublicFormController.php +++ b/api/app/Http/Controllers/Forms/PublicFormController.php @@ -97,7 +97,7 @@ public function answer(AnswerFormRequest $request, FormSubmissionProcessor $form } $isPartial = $request->get('is_partial') ?? false; - if ($isPartial) { + if ($isPartial && $form->enable_partial_submissions && $form->is_pro) { $submissionResponse = $form->submissions()->updateOrCreate([ 'id' => $submissionId ], [ diff --git a/client/components/open/forms/components/form-components/FormSubmissionSettings.vue b/client/components/open/forms/components/form-components/FormSubmissionSettings.vue index a39d93d7a..109f5dc18 100644 --- a/client/components/open/forms/components/form-components/FormSubmissionSettings.vue +++ b/client/components/open/forms/components/form-components/FormSubmissionSettings.vue @@ -30,14 +30,23 @@ class="mt-4" name="enable_partial_submissions" :form="form" - label="Enable partial submissions" help="Allow users to submit incomplete forms. Useful for long forms where you want to analyze drop-off points." - /> + > + + From e196f95526f170586d4b9146efb38636d8f4fc53 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Thu, 27 Feb 2025 15:34:39 +0530 Subject: [PATCH 12/21] start partial sync when dataFormValue update --- client/components/open/forms/OpenForm.vue | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/client/components/open/forms/OpenForm.vue b/client/components/open/forms/OpenForm.vue index f9aeef9ed..a16bdee90 100644 --- a/client/components/open/forms/OpenForm.vue +++ b/client/components/open/forms/OpenForm.vue @@ -203,6 +203,7 @@ export default { * Used to force refresh components by changing their keys */ isAutoSubmit: false, + partialSubmissionStarted: false, } }, @@ -322,10 +323,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 + } } }, @@ -358,9 +364,6 @@ export default { this.isAutoSubmit = true this.submitForm() } - if (!this.adminPreview && this.form?.enable_partial_submissions) { - this.partialSubmission.startSync() - } }, beforeUnmount() { if (!this.adminPreview && this.form?.enable_partial_submissions) { From 7629f3b7ee23964ef4cb98c13b919577aee12b03 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Thu, 27 Feb 2025 15:36:56 +0530 Subject: [PATCH 13/21] badge size xs --- client/components/open/tables/OpenTable.vue | 1 + 1 file changed, 1 insertion(+) diff --git a/client/components/open/tables/OpenTable.vue b/client/components/open/tables/OpenTable.vue index 7eaf1f7f1..39caeb4ba 100644 --- a/client/components/open/tables/OpenTable.vue +++ b/client/components/open/tables/OpenTable.vue @@ -106,6 +106,7 @@ :label="row.status === 'partial' ? 'In Progress' : 'Submitted'" :color="row.status === 'partial' ? 'yellow' : 'green'" variant="soft" + size="xs" /> Date: Mon, 3 Mar 2025 11:56:35 +0530 Subject: [PATCH 14/21] Refactor partial submission hash management --- client/components/open/forms/OpenForm.vue | 2 +- .../composables/forms/usePartialSubmission.js | 22 ++++++++++++++----- client/stores/working_form.js | 9 +------- 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/client/components/open/forms/OpenForm.vue b/client/components/open/forms/OpenForm.vue index a16bdee90..da3835307 100644 --- a/client/components/open/forms/OpenForm.vue +++ b/client/components/open/forms/OpenForm.vue @@ -385,7 +385,7 @@ export default { this.$refs['form-timer'].stopTimer() this.dataForm.completion_time = this.$refs['form-timer'].completionTime if (this.form?.enable_partial_submissions) { - this.dataForm.submission_hash = this.workingFormStore.getSubmissionHash(this.pendingSubmission.formPendingSubmissionKey.value) + this.dataForm.submission_hash = this.partialSubmission.getSubmissionHash() } this.$emit('submit', this.dataForm, this.onSubmissionFailure) diff --git a/client/composables/forms/usePartialSubmission.js b/client/composables/forms/usePartialSubmission.js index cd62da88f..0161a9e3a 100644 --- a/client/composables/forms/usePartialSubmission.js +++ b/client/composables/forms/usePartialSubmission.js @@ -1,15 +1,24 @@ import { opnFetch } from "./../useOpnApi.js" import { pendingSubmission as pendingSubmissionFunction } from "./pendingSubmission.js" -import { useWorkingFormStore } from "~/stores/working_form.js" + +// Create a Map to store submission hashes for different forms +const submissionHashes = ref(new Map()) export const usePartialSubmission = (form, formData = {}) => { const pendingSubmission = pendingSubmissionFunction(form) - const workingFormStore = useWorkingFormStore() const SYNC_INTERVAL = 30000 // 30 seconds let syncInterval = null let syncTimeout = null + const getSubmissionHash = () => { + return submissionHashes.value.get(pendingSubmission.formPendingSubmissionKey.value) + } + + const setSubmissionHash = (hash) => { + submissionHashes.value.set(pendingSubmission.formPendingSubmissionKey.value, hash) + } + const debouncedSync = () => { if (syncTimeout) clearTimeout(syncTimeout) syncTimeout = setTimeout(() => { @@ -21,17 +30,16 @@ export const usePartialSubmission = (form, formData = {}) => { if (!form?.enable_partial_submissions || !formData.value.data() || Object.keys(formData.value.data()).length === 0) return try { - const submissionHash = workingFormStore.getSubmissionHash(pendingSubmission.formPendingSubmissionKey.value) const response = await opnFetch(`/forms/${form.slug}/answer`, { method: "POST", body: { ...formData.value.data(), 'is_partial': true, - 'submission_hash': submissionHash + 'submission_hash': getSubmissionHash() } }) if (response.submission_hash) { - workingFormStore.setSubmissionHash(pendingSubmission.formPendingSubmissionKey.value, response.submission_hash) + setSubmissionHash(response.submission_hash) } } catch (error) { console.error('Failed to sync partial submission', error) @@ -89,6 +97,8 @@ export const usePartialSubmission = (form, formData = {}) => { return { startSync, stopSync, - syncToServer + syncToServer, + getSubmissionHash, + setSubmissionHash } } \ No newline at end of file diff --git a/client/stores/working_form.js b/client/stores/working_form.js index 40679f4da..143f811bf 100644 --- a/client/stores/working_form.js +++ b/client/stores/working_form.js @@ -9,7 +9,6 @@ export const useWorkingFormStore = defineStore("working_form", { content: null, activeTab: 0, formPageIndex: 0, - submissionHash: {}, // Field being edited selectedFieldIndex: null, @@ -157,13 +156,7 @@ export const useWorkingFormStore = defineStore("working_form", { const field = newFields.splice(oldIndex, 1)[0] newFields.splice(newIndex, 0, field) this.content.properties = newFields - }, - setSubmissionHash(key, hash) { - this.submissionHash[key] = hash - }, - getSubmissionHash(key) { - return this.submissionHash[key] ?? null - }, + } }, history: {} }) From ca0524ceb8ed59e91f639271b07e2539582ac9c4 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Mon, 3 Mar 2025 17:52:46 +0530 Subject: [PATCH 15/21] Refactor partial form submission handling in PublicFormController --- .../Forms/PublicFormController.php | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/api/app/Http/Controllers/Forms/PublicFormController.php b/api/app/Http/Controllers/Forms/PublicFormController.php index bbbee5531..f04fcae16 100644 --- a/api/app/Http/Controllers/Forms/PublicFormController.php +++ b/api/app/Http/Controllers/Forms/PublicFormController.php @@ -85,6 +85,23 @@ public function showAsset($assetFileName) return redirect()->to($internal_url); } + private function handlePartialSubmissions(Request $request, $submissionId) + { + $form = $request->form; + $submissionResponse = $form->submissions()->updateOrCreate([ + 'id' => $submissionId + ], [ + 'data' => $request->all(), + 'status' => FormSubmission::STATUS_PARTIAL + ]); + $submissionId = $submissionResponse->id; + + return $this->success([ + 'message' => 'Partial submission saved', + 'submission_hash' => Hashids::encode($submissionId) + ]); + } + public function answer(AnswerFormRequest $request, FormSubmissionProcessor $formSubmissionProcessor) { $form = $request->form; @@ -98,18 +115,7 @@ public function answer(AnswerFormRequest $request, FormSubmissionProcessor $form $isPartial = $request->get('is_partial') ?? false; if ($isPartial && $form->enable_partial_submissions && $form->is_pro) { - $submissionResponse = $form->submissions()->updateOrCreate([ - 'id' => $submissionId - ], [ - 'data' => $request->all(), - 'status' => FormSubmission::STATUS_PARTIAL - ]); - $submissionId = $submissionResponse->id; - - return $this->success([ - 'message' => 'Partial submission saved', - 'submission_hash' => Hashids::encode($submissionId) - ]); + return $this->handlePartialSubmissions($request, $submissionId); } $submissionData = $request->validated(); From f279b87ac58a65c6c6eb6e2e1d104105aa27cfdb Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Mon, 3 Mar 2025 18:14:09 +0530 Subject: [PATCH 16/21] fix submissiona --- api/app/Jobs/Form/StoreFormSubmissionJob.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/app/Jobs/Form/StoreFormSubmissionJob.php b/api/app/Jobs/Form/StoreFormSubmissionJob.php index 9c760833a..b92459fcd 100644 --- a/api/app/Jobs/Form/StoreFormSubmissionJob.php +++ b/api/app/Jobs/Form/StoreFormSubmissionJob.php @@ -95,7 +95,7 @@ private function storeSubmission(array $formData) */ private function submissionToUpdate(): ?FormSubmission { - if ($this->submissionId && is_int($this->submissionId)) { + if ($this->submissionId) { return $this->form->submissions()->findOrFail($this->submissionId); } if ($this->form->editable_submissions && isset($this->submissionData['submission_id']) && $this->submissionData['submission_id']) { From 2cb913905abca0e42776da88d7ff4d0a21ca3540 Mon Sep 17 00:00:00 2001 From: Julien Nahum Date: Wed, 5 Mar 2025 21:44:41 +0800 Subject: [PATCH 17/21] Refactor form submission ID handling and metadata processing - Improve submission ID extraction and decoding across controllers - Add robust handling for submission hash and ID conversion - Enhance metadata processing in StoreFormSubmissionJob - Simplify submission storage logic with clearer metadata extraction - Minor UI improvements in FormSubmissions and OpenTable components --- .../Forms/FormSubmissionController.php | 10 +- .../Forms/PublicFormController.php | 86 +++++++---- api/app/Jobs/Form/StoreFormSubmissionJob.php | 133 +++++++++++------- .../open/forms/components/FormSubmissions.vue | 1 + client/components/open/tables/OpenTable.vue | 2 + 5 files changed, 157 insertions(+), 75 deletions(-) diff --git a/api/app/Http/Controllers/Forms/FormSubmissionController.php b/api/app/Http/Controllers/Forms/FormSubmissionController.php index 8a15bc12f..9b77eff1e 100644 --- a/api/app/Http/Controllers/Forms/FormSubmissionController.php +++ b/api/app/Http/Controllers/Forms/FormSubmissionController.php @@ -36,8 +36,10 @@ public function update(AnswerFormRequest $request, $id, $submissionId) { $form = $request->form; $this->authorize('update', $form); - $job = new StoreFormSubmissionJob($request->form, $request->validated()); - $job->setSubmissionId($submissionId)->handle(); + + $submissionData['submission_id'] = $submissionId; + $job = new StoreFormSubmissionJob($request->form, $submissionData); + $job->handle(); $data = new FormSubmissionResource(FormSubmission::findOrFail($submissionId)); @@ -53,7 +55,7 @@ public function export(FormSubmissionExportRequest $request, string $id) $this->authorize('view', $form); $allRows = []; - $displayColumns = collect($request->columns)->filter(fn ($value, $key) => $value === true)->toArray(); + $displayColumns = collect($request->columns)->filter(fn($value, $key) => $value === true)->toArray(); foreach ($form->submissions->toArray() as $row) { $formatter = (new FormSubmissionFormatter($form, $row['data'])) ->outputStringsOnly() @@ -64,7 +66,7 @@ public function export(FormSubmissionExportRequest $request, string $id) $formattedData = $formatter->getCleanKeyValue(); $filteredData = ['id' => Hashids::encode($row['id'])]; foreach ($displayColumns as $column => $value) { - $key = collect($formattedData)->keys()->first(fn ($key) => str_contains($key, $column)); + $key = collect($formattedData)->keys()->first(fn($key) => str_contains($key, $column)); if ($key) { $filteredData[$key] = $formattedData[$key]; } diff --git a/api/app/Http/Controllers/Forms/PublicFormController.php b/api/app/Http/Controllers/Forms/PublicFormController.php index f04fcae16..a6c30cfa0 100644 --- a/api/app/Http/Controllers/Forms/PublicFormController.php +++ b/api/app/Http/Controllers/Forms/PublicFormController.php @@ -85,13 +85,25 @@ public function showAsset($assetFileName) return redirect()->to($internal_url); } - private function handlePartialSubmissions(Request $request, $submissionId) + /** + * Handle partial form submissions + * + * @param Request $request + * @return \Illuminate\Http\JsonResponse + */ + 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' => $request->all(), + 'data' => $submissionData, 'status' => FormSubmission::STATUS_PARTIAL ]); $submissionId = $submissionResponse->id; @@ -106,51 +118,77 @@ public function answer(AnswerFormRequest $request, FormSubmissionProcessor $form { $form = $request->form; $isFirstSubmission = ($form->submissions_count === 0); - $submissionId = false; - $submissionHash = $request->get('submission_hash') ?? null; - if ($submissionHash) { - $submissionHash = Hashids::decode($submissionHash); - $submissionId = (int)($submissionHash[0] ?? null); - } + // Handle partial submissions $isPartial = $request->get('is_partial') ?? false; if ($isPartial && $form->enable_partial_submissions && $form->is_pro) { - return $this->handlePartialSubmissions($request, $submissionId); + return $this->handlePartialSubmissions($request); } + // Get validated data (includes all metadata) $submissionData = $request->validated(); - $completionTime = $request->get('completion_time') ?? null; - // Remove extra fields from the main data array - unset($submissionData['completion_time']); - unset($submissionData['submission_hash']); - - // Add submission_id to the submission data if it exists - if ($submissionId) { - $submissionData['submission_id'] = $submissionId; - } - $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))); } + /** + * Process submission hash and ID to ensure consistent 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/Jobs/Form/StoreFormSubmissionJob.php b/api/app/Jobs/Form/StoreFormSubmissionJob.php index b92459fcd..64e22582c 100644 --- a/api/app/Jobs/Form/StoreFormSubmissionJob.php +++ b/api/app/Jobs/Form/StoreFormSubmissionJob.php @@ -17,8 +17,21 @@ 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 + * + * These metadata fields will be automatically extracted and removed from the stored form data. + */ class StoreFormSubmissionJob implements ShouldQueue { use Dispatchable; @@ -26,17 +39,18 @@ class StoreFormSubmissionJob implements ShouldQueue use Queueable; use SerializesModels; - public ?string $submissionId = null; + public ?int $submissionId = null; private ?array $formData = null; + private ?int $completionTime = null; /** * 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) {} /** * Execute the job. @@ -45,67 +59,85 @@ public function __construct(public Form $form, public array $submissionData, pub */ 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); } - public function getSubmissionId() + /** + * Extract metadata from submission data + * + * This method extracts and removes metadata fields from the submission data: + * - submission_id + * - completion_time + */ + private function extractMetadata(): void { - return $this->submissionId; - } + // Extract completion time + if (isset($this->submissionData['completion_time'])) { + $this->completionTime = $this->submissionData['completion_time']; + unset($this->submissionData['completion_time']); + } - public function setSubmissionId(int $id) - { - $this->submissionId = $id; + // 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']); + } - return $this; + // Remove is_partial flag if present + if (isset($this->submissionData['is_partial'])) { + unset($this->submissionData['is_partial']); + } } - private function storeSubmission(array $formData) + /** + * Get the submission ID + * + * @return int|null + */ + public function getSubmissionId() { - // If submission_id is set, use it - if (isset($this->submissionData['submission_id']) && $this->submissionData['submission_id'] && is_int($this->submissionData['submission_id'])) { - $this->submissionId = $this->submissionData['submission_id']; - } - - // Create or update record - if ($previousSubmission = $this->submissionToUpdate()) { - $previousSubmission->data = $formData; - $previousSubmission->completion_time = $this->completionTime; - $previousSubmission->status = FormSubmission::STATUS_COMPLETED; - $previousSubmission->save(); - $this->submissionId = $previousSubmission->id; - } else { - $response = $this->form->submissions()->create([ - 'data' => $formData, - 'completion_time' => $this->completionTime, - 'status' => FormSubmission::STATUS_COMPLETED, - ]); - $this->submissionId = $response->id; - } + return $this->submissionId; } /** - * Search for Submission record to update and returns it + * Store the submission in the database + * + * @param array $formData */ - private function submissionToUpdate(): ?FormSubmission + private function storeSubmission(array $formData) { - if ($this->submissionId) { - return $this->form->submissions()->findOrFail($this->submissionId); + // Find existing submission or create a new one + $submission = $this->submissionId + ? $this->form->submissions()->findOrFail($this->submissionId) + : new FormSubmission(); + + // Set submission properties + if (!$this->submissionId) { + $submission->form_id = $this->form->id; } - 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); - } + $submission->data = $formData; + $submission->completion_time = $this->completionTime; + $submission->status = FormSubmission::STATUS_COMPLETED; + $submission->save(); - return null; + // Store the submission ID + $this->submissionId = $submission->id; } /** @@ -213,7 +245,7 @@ private function storeFile(?string $value) $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, @@ -268,13 +300,20 @@ private function addHiddenPrefills(array &$formData): void } /** - * 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/client/components/open/forms/components/FormSubmissions.vue b/client/components/open/forms/components/FormSubmissions.vue index e14c8d58e..1af1a1e4d 100644 --- a/client/components/open/forms/components/FormSubmissions.vue +++ b/client/components/open/forms/components/FormSubmissions.vue @@ -43,6 +43,7 @@

Date: Thu, 6 Mar 2025 12:35:30 +0800 Subject: [PATCH 18/21] Enhance form submission settings UI with advanced partial submission options - Restructure partial submissions toggle with more descriptive label - Add advanced submission options section with Pro tag - Improve help text for partial submissions feature - Update ProTag with more detailed upgrade modal description --- .../FormSubmissionSettings.vue | 42 +++++++++++-------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/client/components/open/forms/components/form-components/FormSubmissionSettings.vue b/client/components/open/forms/components/form-components/FormSubmissionSettings.vue index 109f5dc18..3ea4d4550 100644 --- a/client/components/open/forms/components/form-components/FormSubmissionSettings.vue +++ b/client/components/open/forms/components/form-components/FormSubmissionSettings.vue @@ -25,23 +25,6 @@ label="Auto save form response" help="Saves form progress, allowing respondents to resume later." /> - - - -

+ +

+ Advanced Submission Options +

+

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

+ + + + +

After Submission Date: Thu, 6 Mar 2025 13:12:17 +0800 Subject: [PATCH 19/21] 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, From d3959f6e5fb966f64dc2ba5741b7d851f97a1d00 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Mon, 10 Mar 2025 14:16:29 +0530 Subject: [PATCH 20/21] Improve partial form submission validation and synchronization --- .../Controllers/Forms/PublicFormController.php | 15 +++++++++++++++ client/components/open/forms/OpenCompleteForm.vue | 12 ++++++++---- client/composables/forms/pendingSubmission.js | 13 +++++++++++++ client/composables/forms/usePartialSubmission.js | 9 +++++++-- 4 files changed, 43 insertions(+), 6 deletions(-) diff --git a/api/app/Http/Controllers/Forms/PublicFormController.php b/api/app/Http/Controllers/Forms/PublicFormController.php index 606b1fd7e..24479073a 100644 --- a/api/app/Http/Controllers/Forms/PublicFormController.php +++ b/api/app/Http/Controllers/Forms/PublicFormController.php @@ -16,6 +16,7 @@ use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Storage; use Vinkla\Hashids\Facades\Hashids; +use Illuminate\Support\Str; class PublicFormController extends Controller { @@ -98,6 +99,20 @@ private function handlePartialSubmissions(Request $request) // 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; diff --git a/client/components/open/forms/OpenCompleteForm.vue b/client/components/open/forms/OpenCompleteForm.vue index 4bc3dcdda..e54d430ab 100644 --- a/client/components/open/forms/OpenCompleteForm.vue +++ b/client/components/open/forms/OpenCompleteForm.vue @@ -303,16 +303,16 @@ 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 }) - - if (this.form?.enable_partial_submissions) { - this.partialSubmission.stopSync() - } const payload = clonedeep({ type: 'form-submitted', @@ -351,6 +351,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 && error.data.message) { useAlert().error(error.data.message) diff --git a/client/composables/forms/pendingSubmission.js b/client/composables/forms/pendingSubmission.js index b321e75de..b45103273 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 @@ -51,6 +62,8 @@ export const pendingSubmission = (form) => { set, get, remove, + setSubmissionHash, + getSubmissionHash, setTimer, removeTimer, getTimer, diff --git a/client/composables/forms/usePartialSubmission.js b/client/composables/forms/usePartialSubmission.js index 31dfc21c1..2e36d596c 100644 --- a/client/composables/forms/usePartialSubmission.js +++ b/client/composables/forms/usePartialSubmission.js @@ -12,11 +12,12 @@ export const usePartialSubmission = (form, formData = {}) => { let dataWatcher = null const getSubmissionHash = () => { - return submissionHashes.value.get(pendingSubmission.formPendingSubmissionKey.value) + return pendingSubmission.getSubmissionHash() ?? submissionHashes.value.get(pendingSubmission.formPendingSubmissionKey.value) } const setSubmissionHash = (hash) => { submissionHashes.value.set(pendingSubmission.formPendingSubmissionKey.value, hash) + pendingSubmission.setSubmissionHash(hash) } const debouncedSync = () => { @@ -92,6 +93,8 @@ export const usePartialSubmission = (form, formData = {}) => { } const stopSync = () => { + submissionHashes.value = new Map() + if (dataWatcher) { dataWatcher() dataWatcher = null @@ -113,7 +116,9 @@ export const usePartialSubmission = (form, formData = {}) => { stopSync() // Final sync attempt before unmounting - syncToServer() + if(getSubmissionHash()) { + syncToServer() + } }) return { From b5914fcd98b13b80dd90d5077808b938d1963413 Mon Sep 17 00:00:00 2001 From: Chirag Chhatrala Date: Mon, 10 Mar 2025 14:19:12 +0530 Subject: [PATCH 21/21] fix lint --- .../Forms/FormSubmissionController.php | 4 ++-- .../Controllers/Forms/PublicFormController.php | 2 +- api/app/Jobs/Form/StoreFormSubmissionJob.php | 18 ++++++++++-------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/api/app/Http/Controllers/Forms/FormSubmissionController.php b/api/app/Http/Controllers/Forms/FormSubmissionController.php index 9b77eff1e..534fd9ffb 100644 --- a/api/app/Http/Controllers/Forms/FormSubmissionController.php +++ b/api/app/Http/Controllers/Forms/FormSubmissionController.php @@ -55,7 +55,7 @@ public function export(FormSubmissionExportRequest $request, string $id) $this->authorize('view', $form); $allRows = []; - $displayColumns = collect($request->columns)->filter(fn($value, $key) => $value === true)->toArray(); + $displayColumns = collect($request->columns)->filter(fn ($value, $key) => $value === true)->toArray(); foreach ($form->submissions->toArray() as $row) { $formatter = (new FormSubmissionFormatter($form, $row['data'])) ->outputStringsOnly() @@ -66,7 +66,7 @@ public function export(FormSubmissionExportRequest $request, string $id) $formattedData = $formatter->getCleanKeyValue(); $filteredData = ['id' => Hashids::encode($row['id'])]; foreach ($displayColumns as $column => $value) { - $key = collect($formattedData)->keys()->first(fn($key) => str_contains($key, $column)); + $key = collect($formattedData)->keys()->first(fn ($key) => str_contains($key, $column)); if ($key) { $filteredData[$key] = $formattedData[$key]; } diff --git a/api/app/Http/Controllers/Forms/PublicFormController.php b/api/app/Http/Controllers/Forms/PublicFormController.php index 24479073a..6b18948f0 100644 --- a/api/app/Http/Controllers/Forms/PublicFormController.php +++ b/api/app/Http/Controllers/Forms/PublicFormController.php @@ -170,7 +170,7 @@ public function answer(AnswerFormRequest $request, FormSubmissionProcessor $form /** * Process submission hash and ID to ensure consistent format - * + * * @param Request $request * @param array $submissionData * @return array diff --git a/api/app/Jobs/Form/StoreFormSubmissionJob.php b/api/app/Jobs/Form/StoreFormSubmissionJob.php index 6293c9c0d..16ca3ce5e 100644 --- a/api/app/Jobs/Form/StoreFormSubmissionJob.php +++ b/api/app/Jobs/Form/StoreFormSubmissionJob.php @@ -21,18 +21,18 @@ /** * 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 @@ -57,7 +57,9 @@ class StoreFormSubmissionJob implements ShouldQueue * @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 function __construct(public Form $form, public array $submissionData) + { + } /** * Execute the job. @@ -87,7 +89,7 @@ public function handle() /** * Extract metadata from submission data - * + * * This method extracts and removes metadata fields from the submission data: * - submission_id * - completion_time @@ -128,7 +130,7 @@ public function getSubmissionId() /** * Store the submission in the database - * + * * @param array $formData */ private function storeSubmission(array $formData) @@ -318,7 +320,7 @@ private function addHiddenPrefills(array &$formData): void /** * Get the processed form data including the submission ID - * + * * @return array */ public function getProcessedData(): array