Partial submissions (#705)

* Implement partial form submissions feature

* Add status filtering for form submissions

* Add Partial Submission in Analytics

* improve partial submission

* fix lint

* Add type checking for submission ID in form submission job

* on form stats Partial Submissions only if enable

* Partial Submissions is PRO Feature

* Partial Submissions is PRO Feature

* improvement migration

* Update form submission status labels to 'Submitted' and 'In Progress'

* start partial sync when dataFormValue update

* badge size xs

* Refactor partial submission hash management

* Refactor partial form submission handling in PublicFormController

* fix submissiona

* 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

* 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

* 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

* Improve partial form submission validation and synchronization

* fix lint

* Refactor submission identifier processing in PublicFormController

- Updated the docblock for the method responsible for processing submission identifiers to clarify its functionality. The method now explicitly states that it converts a submission hash or string ID into a numeric submission_id, ensuring consistent internal storage format.

These changes aim to improve code documentation and enhance understanding of the method's purpose and behavior.

* Enhance Form Logic Condition Checker to Exclude Partial Submissions

- Updated the query in FormLogicConditionChecker to exclude submissions with a status of 'partial', ensuring that only complete submissions are processed.
- Minor formatting adjustment in the docblock of PublicFormController for improved clarity.

These changes aim to refine submission handling and enhance the accuracy of form logic evaluations.

* Partial Submission Test

* Refactor FormSubmissionController and PartialSubmissionTest for Consistency

- Updated the `FormSubmissionController` to improve code consistency by adjusting the formatting of anonymous functions in the `filter` and `first` methods.
- Modified `PartialSubmissionTest` to simplify the `Storage::fake()` method call, removing the unnecessary 'local' parameter for better clarity.

These changes aim to enhance code readability and maintainability across the form submission handling and testing components.

* Enhance FormSubmissionController and EditSubmissionTest for Clarity

- Added validation to the `FormSubmissionController` by introducing `$submissionData = $request->validated();` to ensure that only validated data is processed for form submissions.
- Improved code readability in the `FormSubmissionController` by adjusting the formatting of anonymous functions in the `filter` and `first` methods.
- Removed unnecessary blank lines in the `EditSubmissionTest` to streamline the test setup.

These changes aim to enhance data integrity during form submissions and improve overall code clarity and maintainability.

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Chirag Chhatrala
2025-04-28 21:03:55 +05:30
committed by GitHub
parent 89885d418e
commit ff1a4d17d8
23 changed files with 784 additions and 92 deletions

View File

@@ -1,7 +1,5 @@
<?php
it('can update form submission', function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);

View File

@@ -0,0 +1,237 @@
<?php
use App\Models\Forms\Form;
use App\Models\Forms\FormSubmission;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
it('can submit form partially and complete it later using submission hash', function () {
$user = $this->actingAsProUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace, [
'enable_partial_submissions' => true
]);
// Initial partial submission
$formData = $this->generateFormSubmissionData($form, ['text' => 'Initial Text']);
$formData['is_partial'] = true;
$partialResponse = $this->postJson(route('forms.answer', $form->slug), $formData)
->assertSuccessful()
->assertJson([
'type' => 'success',
'message' => 'Partial submission saved',
]);
$submissionHash = $partialResponse->json('submission_hash');
expect($submissionHash)->not->toBeEmpty();
// Complete the submission using the hash
$completeData = $this->generateFormSubmissionData($form, [
'text' => 'Complete Text',
'email' => 'test@example.com'
]);
$completeData['submission_hash'] = $submissionHash;
$this->postJson(route('forms.answer', $form->slug), $completeData)
->assertSuccessful()
->assertJson([
'type' => 'success',
'message' => 'Form submission saved.',
]);
// Verify final submission state
$submission = FormSubmission::first();
expect($submission->status)->toBe(FormSubmission::STATUS_COMPLETED);
expect($submission->data)->toHaveKey(array_key_first($completeData));
});
it('can update partial submission multiple times', function () {
$user = $this->actingAsProUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace, [
'enable_partial_submissions' => true
]);
$targetField = collect($form->properties)->where('name', 'Name')->first();
// First partial submission
$formData = $this->generateFormSubmissionData($form, [$targetField['id'] => 'First Draft']);
$formData['is_partial'] = true;
$firstResponse = $this->postJson(route('forms.answer', $form->slug), $formData)
->assertSuccessful();
$submissionHash = $firstResponse->json('submission_hash');
// Second partial update
$secondData = $this->generateFormSubmissionData($form, [$targetField['id'] => 'Second Draft']);
$secondData['is_partial'] = true;
$secondData['submission_hash'] = $submissionHash;
$this->postJson(route('forms.answer', $form->slug), $secondData)
->assertSuccessful();
// Verify submission was updated
$submission = FormSubmission::first();
expect($submission->status)->toBe(FormSubmission::STATUS_PARTIAL);
expect($submission->data)->toHaveKey(array_key_first($secondData));
expect($submission->data[array_key_first($secondData)])->toBe('Second Draft');
});
it('calculates stats correctly for partial vs completed submissions', function () {
$user = $this->actingAsProUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace, [
'enable_partial_submissions' => true
]);
// Create partial submission
$partialData = $this->generateFormSubmissionData($form, ['text' => 'Partial']);
$partialData['is_partial'] = true;
$this->postJson(route('forms.answer', $form->slug), $partialData);
// Create completed submission
$completeData = $this->generateFormSubmissionData($form, ['text' => 'Complete']);
$this->postJson(route('forms.answer', $form->slug), $completeData);
// Verify stats
$form->refresh();
expect($form->submissions()->where('status', FormSubmission::STATUS_PARTIAL)->count())->toBe(1);
expect($form->submissions()->where('status', FormSubmission::STATUS_COMPLETED)->count())->toBe(1);
});
it('handles file uploads in partial submissions', function () {
Storage::fake();
$user = $this->actingAsProUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace, [
'enable_partial_submissions' => true
]);
// Create a fake file
$file = UploadedFile::fake()->create('test.pdf', 100);
// First partial submission with file
$formData = $this->generateFormSubmissionData($form);
$fileFieldId = collect($form->properties)->where('type', 'files')->first()['id'];
$formData[$fileFieldId] = $file;
$formData['is_partial'] = true;
$response = $this->postJson(route('forms.answer', $form->slug), $formData)
->assertSuccessful();
$submissionHash = $response->json('submission_hash');
// Complete the submission
$completeData = $this->generateFormSubmissionData($form, ['text' => 'Complete']);
$completeData['submission_hash'] = $submissionHash;
$this->postJson(route('forms.answer', $form->slug), $completeData)
->assertSuccessful();
// Verify file was preserved
$submission = FormSubmission::first();
expect($submission->data)->toHaveKey($fileFieldId);
$filePath = str_replace('storage/', '', $submission->data[$fileFieldId]);
expect(Storage::disk('local')->exists($filePath))->toBeTrue();
});
it('handles signature field in partial submissions', function () {
Storage::fake();
$user = $this->actingAsProUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace, [
'enable_partial_submissions' => true
]);
// Create partial submission with signature
$formData = $this->generateFormSubmissionData($form);
$signatureFieldId = collect($form->properties)->where('type', 'files')->first()['id'];
$formData[$signatureFieldId] = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...'; // Base64 signature data
$formData['is_partial'] = true;
$response = $this->postJson(route('forms.answer', $form->slug), $formData)
->assertSuccessful();
$submissionHash = $response->json('submission_hash');
// Complete the submission
$completeData = $this->generateFormSubmissionData($form, ['text' => 'Complete']);
$completeData['submission_hash'] = $submissionHash;
$this->postJson(route('forms.answer', $form->slug), $completeData)
->assertSuccessful();
// Verify signature was preserved
$submission = FormSubmission::first();
expect($submission->data)->toHaveKey($signatureFieldId);
$filePath = str_replace('storage/', '', $submission->data[$signatureFieldId]);
expect(Storage::disk('local')->exists($filePath))->toBeTrue();
});
it('requires at least one field with value for partial submission', function () {
$user = $this->actingAsProUser();
$workspace = $this->createUserWorkspace($user);
$form = $this->createForm($user, $workspace, [
'enable_partial_submissions' => true
]);
// Try to submit with empty data
$formData = ['is_partial' => true];
$this->postJson(route('forms.answer', $form->slug), $formData)
->assertStatus(422)
->assertJson([
'message' => 'At least one field must have a value for partial submissions.'
]);
});
it('submits as completed when partial feature is disabled', function () {
$user = $this->actingAsProUser();
$workspace = $this->createUserWorkspace($user);
// Create form with partial submissions disabled
$form = $this->createForm($user, $workspace, [
'enable_partial_submissions' => false
]);
$formData = $this->generateFormSubmissionData($form, ['text' => 'Test']);
$formData['is_partial'] = true;
$this->postJson(route('forms.answer', $form->slug), $formData)
->assertSuccessful()
->assertJson([
'type' => 'success',
'message' => 'Form submission saved.',
]);
// Verify submission was saved as completed
$submission = FormSubmission::first();
expect($submission->status)->toBe(FormSubmission::STATUS_COMPLETED);
});
it('submits as completed on non-pro forms', function () {
$user = $this->actingAsUser();
$workspace = $this->createUserWorkspace($user);
// Create non-pro form with partial submissions enabled
$form = $this->createForm($user, $workspace, [
'enable_partial_submissions' => true
]);
$formData = $this->generateFormSubmissionData($form, ['text' => 'Test']);
$formData['is_partial'] = true;
$this->postJson(route('forms.answer', $form->slug), $formData)
->assertSuccessful()
->assertJson([
'type' => 'success',
'message' => 'Form submission saved.',
]);
// Verify submission was saved as completed
$submission = FormSubmission::first();
expect($submission->status)->toBe(FormSubmission::STATUS_COMPLETED);
});