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

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Forms;
use App\Http\Controllers\Controller;
use App\Http\Requests\FormStatsRequest;
use App\Models\Forms\FormSubmission;
use Carbon\CarbonPeriod;
use Carbon\CarbonInterval;
use Illuminate\Http\Request;
@@ -21,13 +22,14 @@ class FormStatsController extends Controller
$this->authorize('view', $form);
$formStats = $form->statistics()->whereBetween('date', [$request->date_from, $request->date_to])->get();
$periodStats = ['views' => [], 'submissions' => []];
$periodStats = ['views' => [], 'submissions' => [], 'partial_submissions' => []];
foreach (CarbonPeriod::create($request->date_from, $request->date_to) as $dateObj) {
$date = $dateObj->format('d-m-Y');
$statisticData = $formStats->where('date', $dateObj->format('Y-m-d'))->first();
$periodStats['views'][$date] = $statisticData->data['views'] ?? 0;
$periodStats['submissions'][$date] = $form->submissions()->whereDate('created_at', $dateObj)->count();
$periodStats['submissions'][$date] = $form->submissions()->whereDate('created_at', $dateObj)->where('status', FormSubmission::STATUS_COMPLETED)->count();
$periodStats['partial_submissions'][$date] = $form->submissions()->whereDate('created_at', $dateObj)->where('status', FormSubmission::STATUS_PARTIAL)->count();
if ($dateObj->toDateString() === now()->toDateString()) {
$periodStats['views'][$date] += $form->views()->count();

View File

@@ -36,8 +36,11 @@ class FormSubmissionController extends Controller
{
$form = $request->form;
$this->authorize('update', $form);
$job = new StoreFormSubmissionJob($request->form, $request->validated());
$job->setSubmissionId($submissionId)->handle();
$submissionData = $request->validated();
$submissionData['submission_id'] = $submissionId;
$job = new StoreFormSubmissionJob($request->form, $submissionData);
$job->handle();
$data = new FormSubmissionResource(FormSubmission::findOrFail($submissionId));

View File

@@ -16,6 +16,7 @@ use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Storage;
use Vinkla\Hashids\Facades\Hashids;
use Illuminate\Support\Str;
class PublicFormController extends Controller
{
@@ -85,38 +86,128 @@ class PublicFormController extends Controller
return redirect()->to($internal_url);
}
/**
* Handle partial form submissions
*
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
private function handlePartialSubmissions(Request $request)
{
$form = $request->form;
// Process submission data to extract submission ID
$submissionData = $this->processSubmissionIdentifiers($request, $request->all());
// Validate that at least one field has a value
$hasValue = false;
foreach ($submissionData as $key => $value) {
if (Str::isUuid($key) && !empty($value)) {
$hasValue = true;
break;
}
}
if (!$hasValue) {
return $this->error([
'message' => 'At least one field must have a value for partial submissions.'
], 422);
}
// Explicitly mark this as a partial submission
$submissionData['is_partial'] = true;
// Use the same job as regular submissions to ensure consistent processing
$job = new StoreFormSubmissionJob($form, $submissionData);
$job->handle();
// Get the submission ID
$submissionId = $job->getSubmissionId();
return $this->success([
'message' => 'Partial submission saved',
'submission_hash' => Hashids::encode($submissionId)
]);
}
public function answer(AnswerFormRequest $request, FormSubmissionProcessor $formSubmissionProcessor)
{
$form = $request->form;
$isFirstSubmission = ($form->submissions_count === 0);
$submissionId = false;
// Handle partial submissions
$isPartial = $request->get('is_partial') ?? false;
if ($isPartial && $form->enable_partial_submissions && $form->is_pro) {
return $this->handlePartialSubmissions($request);
}
// Get validated data (includes all metadata)
$submissionData = $request->validated();
$completionTime = $request->get('completion_time') ?? null;
unset($submissionData['completion_time']); // Remove completion_time from the main data array
$job = new StoreFormSubmissionJob($form, $submissionData, $completionTime);
// Process submission hash and ID
$submissionData = $this->processSubmissionIdentifiers($request, $submissionData);
// Create the job with all data (including metadata)
$job = new StoreFormSubmissionJob($form, $submissionData);
// Process the submission
if ($formSubmissionProcessor->shouldProcessSynchronously($form)) {
$job->handle();
$submissionId = Hashids::encode($job->getSubmissionId());
$encodedSubmissionId = Hashids::encode($job->getSubmissionId());
// Update submission data with generated values for redirect URL
$submissionData = $job->getProcessedData();
} else {
StoreFormSubmissionJob::dispatch($form, $submissionData, $completionTime);
$job->handle();
$encodedSubmissionId = Hashids::encode($job->getSubmissionId());
}
// Return the response
return $this->success(array_merge([
'message' => 'Form submission saved.',
'submission_id' => $submissionId,
'submission_id' => $encodedSubmissionId,
'is_first_submission' => $isFirstSubmission,
], $formSubmissionProcessor->getRedirectData($form, $submissionData)));
}
/**
* Processes submission identifiers to ensure consistent numeric format
*
* Takes a submission hash or string ID and converts it to a numeric submission_id.
* This allows submissions to be identified by either a hashed value or direct ID
* while ensuring consistent internal storage format.
*
* @param Request $request
* @param array $submissionData
* @return array
*/
private function processSubmissionIdentifiers(Request $request, array $submissionData): array
{
// Handle submission hash if present (convert to numeric submission_id)
$submissionHash = $request->get('submission_hash');
if ($submissionHash) {
$decodedHash = Hashids::decode($submissionHash);
if (!empty($decodedHash)) {
$submissionData['submission_id'] = (int)($decodedHash[0] ?? null);
}
unset($submissionData['submission_hash']);
}
// Handle string submission_id if present (convert to numeric)
if (isset($submissionData['submission_id']) && is_string($submissionData['submission_id']) && !is_numeric($submissionData['submission_id'])) {
$decodedId = Hashids::decode($submissionData['submission_id']);
if (!empty($decodedId)) {
$submissionData['submission_id'] = (int)($decodedId[0] ?? null);
}
}
return $submissionData;
}
public function fetchSubmission(Request $request, string $slug, string $submissionId)
{
$submissionId = ($submissionId) ? Hashids::decode($submissionId) : false;
$submissionId = isset($submissionId[0]) ? $submissionId[0] : false;
// Decode the submission ID using the same approach as in processSubmissionIdentifiers
$decodedId = Hashids::decode($submissionId);
$submissionId = !empty($decodedId) ? (int)($decodedId[0]) : false;
$form = Form::whereSlug($slug)->whereVisibility('public')->firstOrFail();
if ($form->workspace == null || !$form->editable_submissions || !$submissionId) {
return $this->error([

View File

@@ -54,6 +54,11 @@ class AnswerFormRequest extends FormRequest
*/
public function rules()
{
// Skip validation if this is a partial submission
if ($this->has('is_partial')) {
return [];
}
$selectionFields = collect($this->form->properties)->filter(function ($pro) {
return in_array($pro['type'], ['select', 'multi_select']);
});

View File

@@ -131,6 +131,7 @@ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest
'show_progress_bar' => 'boolean',
'auto_save' => 'boolean',
'auto_focus' => 'boolean',
'enable_partial_submissions' => 'boolean',
// Properties
'properties' => 'required|array',

View File

@@ -40,6 +40,7 @@ class FormSubmissionResource extends JsonResource
private function addExtraData()
{
$this->data = array_merge($this->data, [
'status' => $this->status,
'created_at' => $this->created_at->toDateTimeString(),
'id' => $this->id,
]);

View File

@@ -17,8 +17,27 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Vinkla\Hashids\Facades\Hashids;
use Illuminate\Support\Facades\Log;
/**
* Job to store form submissions
*
* This job handles the storage of form submissions, including processing of metadata
* and special field types like files and signatures.
*
* The job accepts all data in the submissionData array, including metadata fields:
* - submission_id: ID of an existing submission to update (must be an integer)
* - completion_time: Time in seconds it took to complete the form
* - is_partial: Whether this is a partial submission (will be stored with STATUS_PARTIAL)
* If not specified, submissions are treated as complete by default.
*
* These metadata fields will be automatically extracted and removed from the stored form data.
*
* For partial submissions:
* - The submission will be stored with STATUS_PARTIAL
* - All file uploads and signatures will be processed normally
* - The submission can later be updated to STATUS_COMPLETED when the user completes the form
*/
class StoreFormSubmissionJob implements ShouldQueue
{
use Dispatchable;
@@ -26,15 +45,19 @@ class StoreFormSubmissionJob implements ShouldQueue
use Queueable;
use SerializesModels;
public ?string $submissionId = null;
public ?int $submissionId = null;
private ?array $formData = null;
private ?int $completionTime = null;
private bool $isPartial = false;
/**
* Create a new job instance.
*
* @param Form $form The form being submitted
* @param array $submissionData Form data including metadata fields (submission_id, completion_time, etc.)
* @return void
*/
public function __construct(public Form $form, public array $submissionData, public ?int $completionTime = null)
public function __construct(public Form $form, public array $submissionData)
{
}
@@ -45,60 +68,95 @@ class StoreFormSubmissionJob implements ShouldQueue
*/
public function handle()
{
// Extract metadata from submission data
$this->extractMetadata();
// Process form data
$this->formData = $this->getFormData();
$this->addHiddenPrefills($this->formData);
// Store the submission
$this->storeSubmission($this->formData);
// Add the submission ID to the form data after storing the submission
$this->formData['submission_id'] = $this->submissionId;
FormSubmitted::dispatch($this->form, $this->formData);
// Only trigger integrations for completed submissions, not partial ones
if (!$this->isPartial) {
FormSubmitted::dispatch($this->form, $this->formData);
}
}
/**
* Extract metadata from submission data
*
* This method extracts and removes metadata fields from the submission data:
* - submission_id
* - completion_time
* - is_partial
*/
private function extractMetadata(): void
{
// Extract completion time
if (isset($this->submissionData['completion_time'])) {
$this->completionTime = $this->submissionData['completion_time'];
unset($this->submissionData['completion_time']);
}
// Extract direct submission ID if present
if (isset($this->submissionData['submission_id']) && $this->submissionData['submission_id']) {
if (is_numeric($this->submissionData['submission_id'])) {
$this->submissionId = (int)$this->submissionData['submission_id'];
}
unset($this->submissionData['submission_id']);
}
// Extract is_partial flag if present, otherwise default to false
if (isset($this->submissionData['is_partial'])) {
$this->isPartial = (bool)$this->submissionData['is_partial'];
unset($this->submissionData['is_partial']);
}
}
/**
* Get the submission ID
*
* @return int|null
*/
public function getSubmissionId()
{
return $this->submissionId;
}
public function setSubmissionId(int $id)
{
$this->submissionId = $id;
return $this;
}
/**
* Store the submission in the database
*
* @param array $formData
*/
private function storeSubmission(array $formData)
{
// Create or update record
if ($previousSubmission = $this->submissionToUpdate()) {
$previousSubmission->data = $formData;
$previousSubmission->completion_time = $this->completionTime;
$previousSubmission->save();
$this->submissionId = $previousSubmission->id;
} else {
$response = $this->form->submissions()->create([
'data' => $formData,
'completion_time' => $this->completionTime,
]);
$this->submissionId = $response->id;
}
}
// Find existing submission or create a new one
$submission = $this->submissionId
? $this->form->submissions()->findOrFail($this->submissionId)
: new FormSubmission();
/**
* Search for Submission record to update and returns it
*/
private function submissionToUpdate(): ?FormSubmission
{
if ($this->submissionId) {
return $this->form->submissions()->findOrFail($this->submissionId);
}
if ($this->form->editable_submissions && isset($this->submissionData['submission_id']) && $this->submissionData['submission_id']) {
$submissionId = $this->submissionData['submission_id'] ? Hashids::decode($this->submissionData['submission_id']) : false;
$submissionId = $submissionId[0] ?? null;
return $this->form->submissions()->findOrFail($submissionId);
// Set submission properties
if (!$this->submissionId) {
$submission->form_id = $this->form->id;
}
return null;
$submission->data = $formData;
$submission->completion_time = $this->completionTime;
// Set the status based on whether this is a partial submission
$submission->status = $this->isPartial
? FormSubmission::STATUS_PARTIAL
: FormSubmission::STATUS_COMPLETED;
$submission->save();
// Store the submission ID
$this->submissionId = $submission->id;
}
/**
@@ -209,7 +267,7 @@ class StoreFormSubmissionJob implements ShouldQueue
$newPath = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $this->form->id);
$completeNewFilename = $newPath . '/' . $fileNameParser->getMovedFileName();
\Log::debug('Moving file to permanent storage.', [
Log::debug('Moving file to permanent storage.', [
'uuid' => $fileNameParser->uuid,
'destination' => $completeNewFilename,
'form_id' => $this->form->id,
@@ -264,13 +322,20 @@ class StoreFormSubmissionJob implements ShouldQueue
}
/**
* Get the processed form data after all transformations
* Get the processed form data including the submission ID
*
* @return array
*/
public function getProcessedData(): array
{
if ($this->formData === null) {
$this->formData = $this->getFormData();
}
return $this->formData;
// Ensure the submission ID is included in the returned data
$data = $this->formData;
$data['submission_id'] = $this->submissionId;
return $data;
}
}

View File

@@ -92,6 +92,7 @@ class Form extends Model implements CachableAttributes
'show_progress_bar',
'auto_save',
'auto_focus',
'enable_partial_submissions',
// Security & Privacy
'can_be_indexed',
@@ -110,6 +111,7 @@ class Form extends Model implements CachableAttributes
'tags' => 'array',
'removed_properties' => 'array',
'seo_meta' => 'object',
'enable_partial_submissions' => 'boolean',
'auto_save' => 'boolean',
];
}
@@ -174,7 +176,7 @@ class Form extends Model implements CachableAttributes
public function getSubmissionsCountAttribute()
{
return $this->submissions()->count();
return $this->submissions()->where('status', FormSubmission::STATUS_COMPLETED)->count();
}
public function getViewsCountAttribute()

View File

@@ -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

View File

@@ -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.',

View File

@@ -320,6 +320,7 @@ class FormLogicConditionChecker
}
return FormSubmission::where('form_id', $formId)
->where('status', '!=', FormSubmission::STATUS_PARTIAL)
->where(function ($query) use ($condition, $fieldValue) {
$fieldId = $condition['property_meta']['id'];

View File

@@ -0,0 +1,38 @@
<?php
use App\Models\Forms\FormSubmission;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class () extends Migration {
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('form_submissions', function (Blueprint $table) {
$table->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');
});
}
};

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);
});