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/Http/Controllers/Forms/FormSubmissionController.php b/api/app/Http/Controllers/Forms/FormSubmissionController.php index 8a15bc12f..534fd9ffb 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)); diff --git a/api/app/Http/Controllers/Forms/PublicFormController.php b/api/app/Http/Controllers/Forms/PublicFormController.php index d1669c03f..6b18948f0 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 { @@ -85,38 +86,124 @@ public function showAsset($assetFileName) 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))); } + /** + * 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/Http/Requests/AnswerFormRequest.php b/api/app/Http/Requests/AnswerFormRequest.php index 6431bbef5..7baa9d8c8 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/Jobs/Form/StoreFormSubmissionJob.php b/api/app/Jobs/Form/StoreFormSubmissionJob.php index d5dd82147..16ca3ce5e 100644 --- a/api/app/Jobs/Form/StoreFormSubmissionJob.php +++ b/api/app/Jobs/Form/StoreFormSubmissionJob.php @@ -17,8 +17,27 @@ 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 @@ 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() - { - return $this->submissionId; + // Only trigger integrations for completed submissions, not partial ones + if (!$this->isPartial) { + FormSubmitted::dispatch($this->form, $this->formData); + } } - public function setSubmissionId(int $id) + /** + * 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 { - $this->submissionId = $id; + // Extract completion time + if (isset($this->submissionData['completion_time'])) { + $this->completionTime = $this->submissionData['completion_time']; + unset($this->submissionData['completion_time']); + } - return $this; + // 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']); + } } - private function storeSubmission(array $formData) + /** + * Get the submission ID + * + * @return int|null + */ + public function getSubmissionId() { - // 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; - } + 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; + + // Set the status based on whether this is a partial submission + $submission->status = $this->isPartial + ? FormSubmission::STATUS_PARTIAL + : FormSubmission::STATUS_COMPLETED; + + $submission->save(); - return null; + // Store the submission ID + $this->submissionId = $submission->id; } /** @@ -206,7 +264,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, @@ -261,13 +319,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/api/app/Models/Forms/Form.php b/api/app/Models/Forms/Form.php index aae7b69a8..6d0409640 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', ]; } @@ -174,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/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/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.', 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..24a26e08a --- /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/client/components/open/forms/OpenCompleteForm.vue b/client/components/open/forms/OpenCompleteForm.vue index d8f87d684..e54d430ab 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() } }, @@ -301,13 +303,17 @@ 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: { @@ -345,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/components/open/forms/OpenForm.vue b/client/components/open/forms/OpenForm.vue index bd41d1e21..a23b0d34e 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 CachedDefaultTheme from "~/lib/forms/themes/CachedDefaultTheme.js" import FormTimer from './FormTimer.vue' @@ -190,6 +191,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 @@ -206,6 +208,7 @@ export default { * Used to force refresh components by changing their keys */ isAutoSubmit: false, + partialSubmissionStarted: false, } }, @@ -334,10 +337,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 + } } }, @@ -371,7 +379,11 @@ export default { this.submitForm() } }, - + beforeUnmount() { + if (!this.adminPreview && this.form?.enable_partial_submissions) { + this.partialSubmission.stopSync() + } + }, methods: { submitForm() { if (!this.isAutoSubmit && this.formPageIndex !== this.fieldGroups.length - 1) return @@ -386,6 +398,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.partialSubmission.getSubmissionHash() + } this.$emit('submit', this.dataForm, this.onSubmissionFailure) }, diff --git a/client/components/open/forms/components/FormStats.vue b/client/components/open/forms/components/FormStats.vue index 22498dbb9..ba9b65f05 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 0ce4ee01d..1af1a1e4d 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 fb945102d..3ea4d4550 100644 --- a/client/components/open/forms/components/form-components/FormSubmissionSettings.vue +++ b/client/components/open/forms/components/form-components/FormSubmissionSettings.vue @@ -29,7 +29,7 @@ + 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 0a7ffc95a..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 @@ -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 000000000..2e36d596c --- /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