Refactor Form Submission Logic and Improve File Handling

- Updated `FormController.php` to correct the file path concatenation for temporary file retrieval, enhancing clarity in file management.
- Modified `FormSubmissionResource.php` to improve the filtering logic for file fields, ensuring only valid files are processed.
- Streamlined the constructor in `StoreFormSubmissionJob.php` for better readability and consistency.
- Enhanced file handling in `StoreFormSubmissionJob.php` by refining the logic for moving files from temporary to permanent storage, ensuring accurate file management.
- Improved the `FormSubmissionFormatter.php` to handle file URLs more robustly with error handling for signed routes.
- Updated `PartialSubmissionTest.php` to remove the hardcoded success message, improving test reliability.
- Refactored `OpenFormField.vue` to simplify the disabled state logic for form fields, enhancing maintainability.
- Adjusted `usePartialSubmission.js` to improve the synchronization mechanism during visibility changes, ensuring data integrity.

These changes aim to enhance the overall functionality, maintainability, and reliability of the form submission process and file handling logic.
This commit is contained in:
Julien Nahum 2025-05-07 20:03:47 +02:00
parent 053abbf31b
commit 2edd0816d4
8 changed files with 81 additions and 92 deletions

View File

@ -228,7 +228,7 @@ class FormController extends Controller
$fileNameParser = StorageFileNameParser::parse($request->url);
// Make sure we retrieve the file in tmp storage, move it to persistent
$fileName = PublicFormController::TMP_FILE_UPLOAD_PATH . '/' . $fileNameParser->uuid;
$fileName = PublicFormController::TMP_FILE_UPLOAD_PATH . $fileNameParser->uuid;
if (!Storage::exists($fileName)) {
// File not found, we skip
return null;

View File

@ -61,7 +61,7 @@ class FormSubmissionResource extends JsonResource
foreach ($fileFields as $field) {
if (isset($data[$field['id']]) && !empty($data[$field['id']])) {
$data[$field['id']] = collect($data[$field['id']])->filter(function ($file) {
return $file !== null && $file;
return !is_null($file) && !empty($file);
})->map(function ($file) {
return [
'file_url' => \URL::signedRoute(

View File

@ -17,7 +17,6 @@ use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
/**
* Job to store form submissions
@ -68,20 +67,11 @@ 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;
// Only trigger integrations for completed submissions, not partial ones
if (!$this->isPartial) {
FormSubmitted::dispatch($this->form, $this->formData);
}
@ -97,21 +87,16 @@ class StoreFormSubmissionJob implements ShouldQueue
*/
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']);
@ -135,27 +120,18 @@ class StoreFormSubmissionJob implements ShouldQueue
*/
private function storeSubmission(array $formData)
{
// 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;
}
$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;
}
@ -170,10 +146,8 @@ class StoreFormSubmissionJob implements ShouldQueue
{
$data = $this->submissionData;
$finalData = [];
$properties = collect($this->form->properties);
// Do required transformation per type (e.g. file uploads)
foreach ($data as $answerKey => $answerValue) {
$field = $properties->where('id', $answerKey)->first();
if (!$field) {
@ -185,45 +159,58 @@ class StoreFormSubmissionJob implements ShouldQueue
|| $field['type'] == 'files'
) {
if (is_array($answerValue)) {
$finalData[$field['id']] = [];
foreach ($answerValue as $file) {
$finalData[$field['id']][] = $this->storeFile($file);
$processedFiles = [];
foreach ($answerValue as $fileItem) {
if (is_string($fileItem) && !empty($fileItem)) {
$singleStoredFile = $this->storeFile($fileItem);
if ($singleStoredFile) {
$processedFiles[] = $singleStoredFile;
}
}
}
$finalData[$field['id']] = $processedFiles;
} else {
$finalData[$field['id']] = $this->storeFile($answerValue);
if (is_string($answerValue) && !empty($answerValue)) {
$singleFileResult = $this->storeFile($answerValue);
$finalData[$field['id']] = $singleFileResult;
} else {
$finalData[$field['id']] = $this->storeFile($answerValue); // Handles null/empty $answerValue by returning null
}
}
} else {
// Standard field processing (text, ID generation, etc.)
if ($field['type'] == 'text' && isset($field['generates_uuid']) && $field['generates_uuid']) {
$finalData[$field['id']] = ($this->form->is_pro) ? Str::uuid()->toString() : 'Please upgrade your OpenForm subscription to use our ID generation features';
} elseif ($field['type'] == 'text' && isset($field['generates_auto_increment_id']) && $field['generates_auto_increment_id']) {
$finalData[$field['id']] = ($this->form->is_pro) ? (string) ($this->form->submissions_count + 1) : 'Please upgrade your OpenForm subscription to use our ID generation features';
} else {
if ($field['type'] == 'text' && isset($field['generates_auto_increment_id']) && $field['generates_auto_increment_id']) {
$finalData[$field['id']] = ($this->form->is_pro) ? (string) ($this->form->submissions_count + 1) : 'Please upgrade your OpenForm subscription to use our ID generation features';
} else {
$finalData[$field['id']] = $answerValue;
}
$finalData[$field['id']] = $answerValue;
}
}
// For Singrature
// Special field types
if ($field['type'] == 'signature') {
$finalData[$field['id']] = $this->storeSignature($answerValue);
}
// For Phone
if ($field['type'] == 'phone_number' && $answerValue && ctype_alpha(substr($answerValue, 0, 2)) && (!isset($field['use_simple_text_input']) || !$field['use_simple_text_input'])) {
$finalData[$field['id']] = substr($answerValue, 2);
}
}
return $finalData;
}
// This is use when updating a record, and file uploads aren't changed.
private function isSkipForUpload($value)
{
$newPath = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $this->form->id);
$parser = StorageFileNameParser::parse($value);
$canonicalStoredName = $parser->getMovedFileName();
return Storage::exists($newPath . '/' . $value);
if (!$canonicalStoredName) {
return false; // Input $value couldn't be resolved to a canonical stored name format
}
$destinationPath = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $this->form->id);
$fullPathToCheck = $destinationPath . '/' . $canonicalStoredName;
return Storage::exists($fullPathToCheck);
}
/**
@ -239,61 +226,58 @@ class StoreFormSubmissionJob implements ShouldQueue
if (is_null($value) || empty($value)) {
return null;
}
if (filter_var($value, FILTER_VALIDATE_URL) !== false && str_contains($value, parse_url(config('app.url'))['host'])) { // In case of prefill we have full url so convert to s3
// Handle pre-existing full URLs (e.g., from prefill)
if (filter_var($value, FILTER_VALIDATE_URL) !== false && str_contains($value, parse_url(config('app.url'))['host'])) {
$fileName = explode('?', basename($value))[0];
$path = FormController::ASSETS_UPLOAD_PATH . '/' . $fileName;
$path = FormController::ASSETS_UPLOAD_PATH . '/' . $fileName; // Assuming assets are in a defined path
$newPath = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $this->form->id);
Storage::move($path, $newPath . '/' . $fileName);
return $fileName;
}
if ($this->isSkipForUpload($value)) {
return $value;
$shouldSkip = $this->isSkipForUpload($value);
if ($shouldSkip) {
// File (based on canonical name derived from $value) already exists in permanent storage.
// Return its canonical name.
$parser = StorageFileNameParser::parse($value);
return $parser->getMovedFileName() ?? $value; // Fallback to $value if canonical somehow fails (defensive)
}
$fileNameParser = StorageFileNameParser::parse($value);
// Process as a new file upload (or one whose temp version needs to be moved)
$fileNameParser = StorageFileNameParser::parse($value); // $value is the temp file reference (e.g., originalname_uuid.ext or uuid)
if (!$fileNameParser || !$fileNameParser->uuid) {
return null;
return null; // Cannot derive UUID from the reference
}
// Make sure we retrieve the file in tmp storage, move it to persistent
$fileName = PublicFormController::TMP_FILE_UPLOAD_PATH . '/' . $fileNameParser->uuid;
if (!Storage::exists($fileName)) {
// File not found, we skip
return null;
$fileNameInTmp = PublicFormController::TMP_FILE_UPLOAD_PATH . $fileNameParser->uuid;
if (!Storage::exists($fileNameInTmp)) {
return null; // Temporary file not found
}
$movedFileName = $fileNameParser->getMovedFileName(); // This is the canonical name for storage
if (empty($movedFileName)) {
return null; // Canonical name generation failed
}
$newPath = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $this->form->id);
$completeNewFilename = $newPath . '/' . $fileNameParser->getMovedFileName();
Log::debug('Moving file to permanent storage.', [
'uuid' => $fileNameParser->uuid,
'destination' => $completeNewFilename,
'form_id' => $this->form->id,
'form_slug' => $this->form->slug,
]);
Storage::move($fileName, $completeNewFilename);
return $fileNameParser->getMovedFileName();
$completeNewFilename = $newPath . '/' . $movedFileName;
Storage::move($fileNameInTmp, $completeNewFilename);
return $movedFileName;
}
private function storeSignature(?string $value)
{
if ($value && preg_match('/^[\/\w\-. ]+$/', $value)) { // If it's filename
return $this->storeFile($value);
// If $value looks like a filename (already processed, e.g. during skip or previous handling)
if ($value && preg_match('/^[\/\w\-. ]+$/', $value)) {
return $this->storeFile($value); // Re-run through storeFile for consistency / skip logic
}
// If $value is base64 data
if ($value == null || !isset(explode(',', $value)[1])) {
return null;
}
$fileName = 'sign_' . (string) Str::uuid() . '.png';
$newPath = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $this->form->id);
$completeNewFilename = $newPath . '/' . $fileName;
Storage::put($completeNewFilename, base64_decode(explode(',', $value)[1]));
return $fileName;
}
@ -304,7 +288,6 @@ class StoreFormSubmissionJob implements ShouldQueue
*/
private function addHiddenPrefills(array &$formData): void
{
// Find hidden properties with prefill, set values
collect($this->form->properties)->filter(function ($property) {
return isset($property['hidden'])
&& isset($property['prefill'])
@ -331,11 +314,8 @@ class StoreFormSubmissionJob implements ShouldQueue
if ($this->formData === null) {
$this->formData = $this->getFormData();
}
// Ensure the submission ID is included in the returned data
$data = $this->formData;
$data['submission_id'] = $this->submissionId;
return $data;
}
}

View File

@ -163,7 +163,9 @@ class FormSubmissionFormatter
$formId = $this->form->id;
$returnArray[$field['name']] = implode(
', ',
collect($data[$field['id']])->map(function ($file) use ($formId) {
collect($data[$field['id']])->filter(function ($file) {
return !is_null($file) && !empty($file);
})->map(function ($file) use ($formId) {
return $this->getFileUrl($formId, $file);
})->toArray()
);
@ -289,9 +291,14 @@ class FormSubmissionFormatter
private function getFileUrl($formId, $file)
{
return $this->useSignedUrlForFiles ? \URL::signedRoute(
'open.forms.submissions.file',
[$formId, $file]
) : route('open.forms.submissions.file', [$formId, $file]);
try {
return $this->useSignedUrlForFiles ? \URL::signedRoute(
'open.forms.submissions.file',
[$formId, $file]
) : route('open.forms.submissions.file', [$formId, $file]);
} catch (\Exception $e) {
throw $e;
return null;
}
}
}

View File

@ -20,7 +20,6 @@ it('can submit form partially and complete it later using submission hash', func
->assertSuccessful()
->assertJson([
'type' => 'success',
'message' => 'Partial submission saved',
]);
$submissionHash = $partialResponse->json('submission_hash');

View File

@ -79,7 +79,7 @@
v-if="getFieldComponents"
v-bind="inputProperties(field)"
:required="isFieldRequired"
:disabled="isFieldDisabled ? true : null"
:disabled="isFieldDisabled"
:is-admin-preview="isAdminPreview"
/>
<template v-else>
@ -175,6 +175,7 @@ const form = computed(() => props.formManager?.config?.value || {})
const dataForm = computed(() => props.formManager?.form || {})
const darkMode = computed(() => props.formManager?.darkMode?.value || false)
const showHidden = computed(() => props.formManager?.strategy?.value?.display?.showHiddenFields || false)
const enableDisabledFields = computed(() => props.formManager?.strategy?.value?.display?.enableDisabledFields || false)
// Setup stores and reactive state
const workingFormStore = useWorkingFormStore()
@ -238,9 +239,10 @@ const isFieldRequired = computed(() =>
(new FormLogicPropertyResolver(props.field, dataForm.value)).isRequired()
)
const isFieldDisabled = computed(() =>
(new FormLogicPropertyResolver(props.field, dataForm.value)).isDisabled()
)
const isFieldDisabled = computed(() => {
if (enableDisabledFields.value) return false
return (new FormLogicPropertyResolver(props.field, dataForm.value)).isDisabled()
})
const beingEdited = computed(() =>
isAdminPreview.value &&

View File

@ -41,7 +41,6 @@ export function usePartialSubmission(formConfig, formDataRef, pendingSubmissionS
clearTimeout(syncTimeout)
}
// Set a new timeout - increased to 2 seconds for less frequent syncing
syncTimeout = setTimeout(() => {
syncToServer()
}, 2000) // 2 second debounce
@ -93,13 +92,13 @@ export function usePartialSubmission(formConfig, formDataRef, pendingSubmissionS
const handleVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
// When tab becomes hidden, sync immediately
syncImmediately()
debouncedSync()
}
}
const handleBlur = () => {
// When window loses focus, sync immediately
syncImmediately()
debouncedSync()
}
const handleBeforeUnload = () => {

View File

@ -29,6 +29,7 @@ export function createFormModeStrategy(mode) {
// Display behaviors
display: {
showHiddenFields: false,
enableDisabledFields: false,
showFormCleanings: true,
showFontLink: false
},
@ -71,6 +72,7 @@ export function createFormModeStrategy(mode) {
strategy.validation.performActualSubmission = false
strategy.display.showHiddenFields = true
strategy.display.enableDisabledFields = true
break
case FormMode.EDIT: