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:
parent
89885d418e
commit
ff1a4d17d8
|
|
@ -4,6 +4,7 @@ namespace App\Http\Controllers\Forms;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Requests\FormStatsRequest;
|
use App\Http\Requests\FormStatsRequest;
|
||||||
|
use App\Models\Forms\FormSubmission;
|
||||||
use Carbon\CarbonPeriod;
|
use Carbon\CarbonPeriod;
|
||||||
use Carbon\CarbonInterval;
|
use Carbon\CarbonInterval;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -21,13 +22,14 @@ class FormStatsController extends Controller
|
||||||
$this->authorize('view', $form);
|
$this->authorize('view', $form);
|
||||||
|
|
||||||
$formStats = $form->statistics()->whereBetween('date', [$request->date_from, $request->date_to])->get();
|
$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) {
|
foreach (CarbonPeriod::create($request->date_from, $request->date_to) as $dateObj) {
|
||||||
$date = $dateObj->format('d-m-Y');
|
$date = $dateObj->format('d-m-Y');
|
||||||
|
|
||||||
$statisticData = $formStats->where('date', $dateObj->format('Y-m-d'))->first();
|
$statisticData = $formStats->where('date', $dateObj->format('Y-m-d'))->first();
|
||||||
$periodStats['views'][$date] = $statisticData->data['views'] ?? 0;
|
$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()) {
|
if ($dateObj->toDateString() === now()->toDateString()) {
|
||||||
$periodStats['views'][$date] += $form->views()->count();
|
$periodStats['views'][$date] += $form->views()->count();
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,11 @@ class FormSubmissionController extends Controller
|
||||||
{
|
{
|
||||||
$form = $request->form;
|
$form = $request->form;
|
||||||
$this->authorize('update', $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));
|
$data = new FormSubmissionResource(FormSubmission::findOrFail($submissionId));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Vinkla\Hashids\Facades\Hashids;
|
use Vinkla\Hashids\Facades\Hashids;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class PublicFormController extends Controller
|
class PublicFormController extends Controller
|
||||||
{
|
{
|
||||||
|
|
@ -85,38 +86,128 @@ class PublicFormController extends Controller
|
||||||
return redirect()->to($internal_url);
|
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)
|
public function answer(AnswerFormRequest $request, FormSubmissionProcessor $formSubmissionProcessor)
|
||||||
{
|
{
|
||||||
$form = $request->form;
|
$form = $request->form;
|
||||||
$isFirstSubmission = ($form->submissions_count === 0);
|
$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();
|
$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)) {
|
if ($formSubmissionProcessor->shouldProcessSynchronously($form)) {
|
||||||
$job->handle();
|
$job->handle();
|
||||||
$submissionId = Hashids::encode($job->getSubmissionId());
|
$encodedSubmissionId = Hashids::encode($job->getSubmissionId());
|
||||||
// Update submission data with generated values for redirect URL
|
// Update submission data with generated values for redirect URL
|
||||||
$submissionData = $job->getProcessedData();
|
$submissionData = $job->getProcessedData();
|
||||||
} else {
|
} else {
|
||||||
StoreFormSubmissionJob::dispatch($form, $submissionData, $completionTime);
|
$job->handle();
|
||||||
|
$encodedSubmissionId = Hashids::encode($job->getSubmissionId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Return the response
|
||||||
return $this->success(array_merge([
|
return $this->success(array_merge([
|
||||||
'message' => 'Form submission saved.',
|
'message' => 'Form submission saved.',
|
||||||
'submission_id' => $submissionId,
|
'submission_id' => $encodedSubmissionId,
|
||||||
'is_first_submission' => $isFirstSubmission,
|
'is_first_submission' => $isFirstSubmission,
|
||||||
], $formSubmissionProcessor->getRedirectData($form, $submissionData)));
|
], $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)
|
public function fetchSubmission(Request $request, string $slug, string $submissionId)
|
||||||
{
|
{
|
||||||
$submissionId = ($submissionId) ? Hashids::decode($submissionId) : false;
|
// Decode the submission ID using the same approach as in processSubmissionIdentifiers
|
||||||
$submissionId = isset($submissionId[0]) ? $submissionId[0] : false;
|
$decodedId = Hashids::decode($submissionId);
|
||||||
|
$submissionId = !empty($decodedId) ? (int)($decodedId[0]) : false;
|
||||||
|
|
||||||
$form = Form::whereSlug($slug)->whereVisibility('public')->firstOrFail();
|
$form = Form::whereSlug($slug)->whereVisibility('public')->firstOrFail();
|
||||||
if ($form->workspace == null || !$form->editable_submissions || !$submissionId) {
|
if ($form->workspace == null || !$form->editable_submissions || !$submissionId) {
|
||||||
return $this->error([
|
return $this->error([
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,11 @@ class AnswerFormRequest extends FormRequest
|
||||||
*/
|
*/
|
||||||
public function rules()
|
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) {
|
$selectionFields = collect($this->form->properties)->filter(function ($pro) {
|
||||||
return in_array($pro['type'], ['select', 'multi_select']);
|
return in_array($pro['type'], ['select', 'multi_select']);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest
|
||||||
'show_progress_bar' => 'boolean',
|
'show_progress_bar' => 'boolean',
|
||||||
'auto_save' => 'boolean',
|
'auto_save' => 'boolean',
|
||||||
'auto_focus' => 'boolean',
|
'auto_focus' => 'boolean',
|
||||||
|
'enable_partial_submissions' => 'boolean',
|
||||||
|
|
||||||
// Properties
|
// Properties
|
||||||
'properties' => 'required|array',
|
'properties' => 'required|array',
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ class FormSubmissionResource extends JsonResource
|
||||||
private function addExtraData()
|
private function addExtraData()
|
||||||
{
|
{
|
||||||
$this->data = array_merge($this->data, [
|
$this->data = array_merge($this->data, [
|
||||||
|
'status' => $this->status,
|
||||||
'created_at' => $this->created_at->toDateTimeString(),
|
'created_at' => $this->created_at->toDateTimeString(),
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,27 @@ use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Illuminate\Support\Str;
|
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
|
class StoreFormSubmissionJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable;
|
use Dispatchable;
|
||||||
|
|
@ -26,15 +45,19 @@ class StoreFormSubmissionJob implements ShouldQueue
|
||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
public ?string $submissionId = null;
|
public ?int $submissionId = null;
|
||||||
private ?array $formData = null;
|
private ?array $formData = null;
|
||||||
|
private ?int $completionTime = null;
|
||||||
|
private bool $isPartial = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new job instance.
|
* 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
|
* @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()
|
public function handle()
|
||||||
{
|
{
|
||||||
|
// Extract metadata from submission data
|
||||||
|
$this->extractMetadata();
|
||||||
|
|
||||||
|
// Process form data
|
||||||
$this->formData = $this->getFormData();
|
$this->formData = $this->getFormData();
|
||||||
$this->addHiddenPrefills($this->formData);
|
$this->addHiddenPrefills($this->formData);
|
||||||
|
|
||||||
|
// Store the submission
|
||||||
$this->storeSubmission($this->formData);
|
$this->storeSubmission($this->formData);
|
||||||
|
|
||||||
|
// Add the submission ID to the form data after storing the submission
|
||||||
$this->formData['submission_id'] = $this->submissionId;
|
$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()
|
public function getSubmissionId()
|
||||||
{
|
{
|
||||||
return $this->submissionId;
|
return $this->submissionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setSubmissionId(int $id)
|
/**
|
||||||
{
|
* Store the submission in the database
|
||||||
$this->submissionId = $id;
|
*
|
||||||
|
* @param array $formData
|
||||||
return $this;
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
private function storeSubmission(array $formData)
|
private function storeSubmission(array $formData)
|
||||||
{
|
{
|
||||||
// Create or update record
|
// Find existing submission or create a new one
|
||||||
if ($previousSubmission = $this->submissionToUpdate()) {
|
$submission = $this->submissionId
|
||||||
$previousSubmission->data = $formData;
|
? $this->form->submissions()->findOrFail($this->submissionId)
|
||||||
$previousSubmission->completion_time = $this->completionTime;
|
: new FormSubmission();
|
||||||
$previousSubmission->save();
|
|
||||||
$this->submissionId = $previousSubmission->id;
|
|
||||||
} else {
|
|
||||||
$response = $this->form->submissions()->create([
|
|
||||||
'data' => $formData,
|
|
||||||
'completion_time' => $this->completionTime,
|
|
||||||
]);
|
|
||||||
$this->submissionId = $response->id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Set submission properties
|
||||||
* Search for Submission record to update and returns it
|
if (!$this->submissionId) {
|
||||||
*/
|
$submission->form_id = $this->form->id;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
$newPath = Str::of(PublicFormController::FILE_UPLOAD_PATH)->replace('?', $this->form->id);
|
||||||
$completeNewFilename = $newPath . '/' . $fileNameParser->getMovedFileName();
|
$completeNewFilename = $newPath . '/' . $fileNameParser->getMovedFileName();
|
||||||
|
|
||||||
\Log::debug('Moving file to permanent storage.', [
|
Log::debug('Moving file to permanent storage.', [
|
||||||
'uuid' => $fileNameParser->uuid,
|
'uuid' => $fileNameParser->uuid,
|
||||||
'destination' => $completeNewFilename,
|
'destination' => $completeNewFilename,
|
||||||
'form_id' => $this->form->id,
|
'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
|
public function getProcessedData(): array
|
||||||
{
|
{
|
||||||
if ($this->formData === null) {
|
if ($this->formData === null) {
|
||||||
$this->formData = $this->getFormData();
|
$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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ class Form extends Model implements CachableAttributes
|
||||||
'show_progress_bar',
|
'show_progress_bar',
|
||||||
'auto_save',
|
'auto_save',
|
||||||
'auto_focus',
|
'auto_focus',
|
||||||
|
'enable_partial_submissions',
|
||||||
|
|
||||||
// Security & Privacy
|
// Security & Privacy
|
||||||
'can_be_indexed',
|
'can_be_indexed',
|
||||||
|
|
@ -110,6 +111,7 @@ class Form extends Model implements CachableAttributes
|
||||||
'tags' => 'array',
|
'tags' => 'array',
|
||||||
'removed_properties' => 'array',
|
'removed_properties' => 'array',
|
||||||
'seo_meta' => 'object',
|
'seo_meta' => 'object',
|
||||||
|
'enable_partial_submissions' => 'boolean',
|
||||||
'auto_save' => 'boolean',
|
'auto_save' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
@ -174,7 +176,7 @@ class Form extends Model implements CachableAttributes
|
||||||
|
|
||||||
public function getSubmissionsCountAttribute()
|
public function getSubmissionsCountAttribute()
|
||||||
{
|
{
|
||||||
return $this->submissions()->count();
|
return $this->submissions()->where('status', FormSubmission::STATUS_COMPLETED)->count();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getViewsCountAttribute()
|
public function getViewsCountAttribute()
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,13 @@ class FormSubmission extends Model
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
||||||
|
public const STATUS_PARTIAL = 'partial';
|
||||||
|
public const STATUS_COMPLETED = 'completed';
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'data',
|
'data',
|
||||||
'completion_time',
|
'completion_time',
|
||||||
|
'status'
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@ class FormCleaner
|
||||||
'editable_submissions' => false,
|
'editable_submissions' => false,
|
||||||
'custom_code' => null,
|
'custom_code' => null,
|
||||||
'seo_meta' => [],
|
'seo_meta' => [],
|
||||||
'redirect_url' => null
|
'redirect_url' => null,
|
||||||
|
'enable_partial_submissions' => false,
|
||||||
];
|
];
|
||||||
|
|
||||||
private array $formNonTrialingDefaults = [
|
private array $formNonTrialingDefaults = [
|
||||||
|
|
@ -54,6 +55,7 @@ class FormCleaner
|
||||||
'custom_code' => 'Custom code was disabled',
|
'custom_code' => 'Custom code was disabled',
|
||||||
'seo_meta' => 'Custom SEO was disabled',
|
'seo_meta' => 'Custom SEO was disabled',
|
||||||
'redirect_url' => 'Redirect Url was disabled',
|
'redirect_url' => 'Redirect Url was disabled',
|
||||||
|
'enable_partial_submissions' => 'Partial submissions were disabled',
|
||||||
|
|
||||||
// For fields
|
// For fields
|
||||||
'file_upload' => 'Link field is not a file upload.',
|
'file_upload' => 'Link field is not a file upload.',
|
||||||
|
|
|
||||||
|
|
@ -320,6 +320,7 @@ class FormLogicConditionChecker
|
||||||
}
|
}
|
||||||
|
|
||||||
return FormSubmission::where('form_id', $formId)
|
return FormSubmission::where('form_id', $formId)
|
||||||
|
->where('status', '!=', FormSubmission::STATUS_PARTIAL)
|
||||||
->where(function ($query) use ($condition, $fieldValue) {
|
->where(function ($query) use ($condition, $fieldValue) {
|
||||||
$fieldId = $condition['property_meta']['id'];
|
$fieldId = $condition['property_meta']['id'];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
it('can update form submission', function () {
|
it('can update form submission', function () {
|
||||||
$user = $this->actingAsUser();
|
$user = $this->actingAsUser();
|
||||||
$workspace = $this->createUserWorkspace($user);
|
$workspace = $this->createUserWorkspace($user);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
@ -190,7 +190,8 @@ import OpenForm from './OpenForm.vue'
|
||||||
import OpenFormButton from './OpenFormButton.vue'
|
import OpenFormButton from './OpenFormButton.vue'
|
||||||
import FormCleanings from '../../pages/forms/show/FormCleanings.vue'
|
import FormCleanings from '../../pages/forms/show/FormCleanings.vue'
|
||||||
import VTransition from '~/components/global/transitions/VTransition.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 clonedeep from "clone-deep"
|
||||||
import ThemeBuilder from "~/lib/forms/themes/ThemeBuilder.js"
|
import ThemeBuilder from "~/lib/forms/themes/ThemeBuilder.js"
|
||||||
import FirstSubmissionModal from '~/components/open/forms/components/FirstSubmissionModal.vue'
|
import FirstSubmissionModal from '~/components/open/forms/components/FirstSubmissionModal.vue'
|
||||||
|
|
@ -225,6 +226,7 @@ export default {
|
||||||
authenticated: computed(() => authStore.check),
|
authenticated: computed(() => authStore.check),
|
||||||
isIframe: useIsIframe(),
|
isIframe: useIsIframe(),
|
||||||
pendingSubmission: pendingSubmission(props.form),
|
pendingSubmission: pendingSubmission(props.form),
|
||||||
|
partialSubmission: usePartialSubmission(props.form),
|
||||||
confetti: useConfetti()
|
confetti: useConfetti()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -298,6 +300,11 @@ export default {
|
||||||
|
|
||||||
if (form.busy) return
|
if (form.busy) return
|
||||||
this.loading = true
|
this.loading = true
|
||||||
|
|
||||||
|
if (this.form?.enable_partial_submissions) {
|
||||||
|
this.partialSubmission.stopSync()
|
||||||
|
}
|
||||||
|
|
||||||
form.post('/forms/' + this.form.slug + '/answer').then((data) => {
|
form.post('/forms/' + this.form.slug + '/answer').then((data) => {
|
||||||
this.submittedData = form.data()
|
this.submittedData = form.data()
|
||||||
useAmplitude().logEvent('form_submission', {
|
useAmplitude().logEvent('form_submission', {
|
||||||
|
|
@ -342,6 +349,10 @@ export default {
|
||||||
this.confetti.play()
|
this.confetti.play()
|
||||||
}
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
|
if (this.form?.enable_partial_submissions) {
|
||||||
|
this.partialSubmission.startSync()
|
||||||
|
}
|
||||||
|
|
||||||
console.error(error)
|
console.error(error)
|
||||||
if (error.response && error.data) {
|
if (error.response && error.data) {
|
||||||
useAlert().formValidationError(error.data)
|
useAlert().formValidationError(error.data)
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,8 @@ import draggable from 'vuedraggable'
|
||||||
import OpenFormButton from './OpenFormButton.vue'
|
import OpenFormButton from './OpenFormButton.vue'
|
||||||
import CaptchaInput from '~/components/forms/components/CaptchaInput.vue'
|
import CaptchaInput from '~/components/forms/components/CaptchaInput.vue'
|
||||||
import OpenFormField from './OpenFormField.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 FormLogicPropertyResolver from "~/lib/forms/FormLogicPropertyResolver.js"
|
||||||
import CachedDefaultTheme from "~/lib/forms/themes/CachedDefaultTheme.js"
|
import CachedDefaultTheme from "~/lib/forms/themes/CachedDefaultTheme.js"
|
||||||
import FormTimer from './FormTimer.vue'
|
import FormTimer from './FormTimer.vue'
|
||||||
|
|
@ -191,6 +192,7 @@ export default {
|
||||||
isIframe: useIsIframe(),
|
isIframe: useIsIframe(),
|
||||||
draggingNewBlock: computed(() => workingFormStore.draggingNewBlock),
|
draggingNewBlock: computed(() => workingFormStore.draggingNewBlock),
|
||||||
pendingSubmission: import.meta.client ? pendingSubmission(props.form) : { get: () => ({}), set: () => {} },
|
pendingSubmission: import.meta.client ? pendingSubmission(props.form) : { get: () => ({}), set: () => {} },
|
||||||
|
partialSubmission: import.meta.client ? usePartialSubmission(props.form, dataForm) : { startSync: () => {}, stopSync: () => {} },
|
||||||
formPageIndex: storeToRefs(workingFormStore).formPageIndex,
|
formPageIndex: storeToRefs(workingFormStore).formPageIndex,
|
||||||
|
|
||||||
// Used for admin previews
|
// Used for admin previews
|
||||||
|
|
@ -203,6 +205,7 @@ export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isAutoSubmit: false,
|
isAutoSubmit: false,
|
||||||
|
partialSubmissionStarted: false,
|
||||||
isInitialLoad: true,
|
isInitialLoad: true,
|
||||||
// Flag to prevent recursion in field group updates
|
// Flag to prevent recursion in field group updates
|
||||||
isUpdatingFieldGroups: false,
|
isUpdatingFieldGroups: false,
|
||||||
|
|
@ -354,10 +357,15 @@ export default {
|
||||||
},
|
},
|
||||||
dataFormValue: {
|
dataFormValue: {
|
||||||
deep: true,
|
deep: true,
|
||||||
handler() {
|
handler(newValue, oldValue) {
|
||||||
if (this.isPublicFormPage && this.form && this.form.auto_save) {
|
if (this.isPublicFormPage && this.form && this.form.auto_save) {
|
||||||
this.pendingSubmission.set(this.dataFormValue)
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -391,34 +399,35 @@ export default {
|
||||||
this.submitForm()
|
this.submitForm()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
if (!this.adminPreview && this.form?.enable_partial_submissions) {
|
||||||
|
this.partialSubmission.stopSync()
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async submitForm() {
|
async submitForm() {
|
||||||
this.dataForm.busy = true
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!await this.nextPage()) {
|
// Process payment if needed
|
||||||
this.dataForm.busy = false
|
if (!await this.doPayment()) {
|
||||||
return
|
return false // Payment failed or was required but not completed
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.isAutoSubmit && this.formPageIndex !== this.fieldGroups.length - 1) {
|
|
||||||
this.dataForm.busy = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.form.use_captcha && import.meta.client) {
|
|
||||||
this.$refs.captcha?.reset()
|
|
||||||
}
|
}
|
||||||
|
this.dataForm.busy = false
|
||||||
|
|
||||||
|
// Add submission_id for editable submissions (from main)
|
||||||
if (this.form.editable_submissions && this.form.submission_id) {
|
if (this.form.editable_submissions && this.form.submission_id) {
|
||||||
this.dataForm.submission_id = this.form.submission_id
|
this.dataForm.submission_id = this.form.submission_id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop timer and get completion time (from main)
|
||||||
this.$refs['form-timer'].stopTimer()
|
this.$refs['form-timer'].stopTimer()
|
||||||
this.dataForm.completion_time = this.$refs['form-timer'].completionTime
|
this.dataForm.completion_time = this.$refs['form-timer'].completionTime
|
||||||
|
|
||||||
// Add validation strategy check
|
// Add submission hash for partial submissions (from HEAD)
|
||||||
|
if (this.form?.enable_partial_submissions) {
|
||||||
|
this.dataForm.submission_hash = this.partialSubmission.getSubmissionHash()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add validation strategy check (from main)
|
||||||
if (!this.formModeStrategy.validation.validateOnSubmit) {
|
if (!this.formModeStrategy.validation.validateOnSubmit) {
|
||||||
this.$emit('submit', this.dataForm, this.onSubmissionFailure)
|
this.$emit('submit', this.dataForm, this.onSubmissionFailure)
|
||||||
return
|
return
|
||||||
|
|
@ -427,6 +436,7 @@ export default {
|
||||||
this.$emit('submit', this.dataForm, this.onSubmissionFailure)
|
this.$emit('submit', this.dataForm, this.onSubmissionFailure)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.handleValidationError(error)
|
this.handleValidationError(error)
|
||||||
|
} finally {
|
||||||
this.dataForm.busy = false
|
this.dataForm.busy = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -666,13 +676,14 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async doPayment() {
|
async doPayment() {
|
||||||
// Use the stripeElements from setup instead of calling useStripeElements
|
|
||||||
const { state: stripeState, processPayment, isCardPopulated, isReadyForPayment } = this.stripeElements
|
|
||||||
|
|
||||||
// Check if there's a payment block in the current step
|
// Check if there's a payment block in the current step
|
||||||
if (!this.paymentBlock) {
|
if (!this.paymentBlock) {
|
||||||
return true // No payment needed for this step
|
return true // No payment needed for this step
|
||||||
}
|
}
|
||||||
|
this.dataForm.busy = true
|
||||||
|
|
||||||
|
// Use the stripeElements from setup instead of calling useStripeElements
|
||||||
|
const { state: stripeState, processPayment, isCardPopulated, isReadyForPayment } = this.stripeElements
|
||||||
|
|
||||||
// Skip if payment is already processed in the stripe state
|
// Skip if payment is already processed in the stripe state
|
||||||
if (stripeState.intentId) {
|
if (stripeState.intentId) {
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,12 @@ export default {
|
||||||
borderColor: "rgba(16, 185, 129, 1)",
|
borderColor: "rgba(16, 185, 129, 1)",
|
||||||
data: [],
|
data: [],
|
||||||
},
|
},
|
||||||
],
|
].concat(this.form.enable_partial_submissions ? [{
|
||||||
|
label: "Partial Submissions",
|
||||||
|
backgroundColor: "rgba(255, 193, 7, 1)",
|
||||||
|
borderColor: "rgba(255, 193, 7, 1)",
|
||||||
|
data: [],
|
||||||
|
}] : []),
|
||||||
},
|
},
|
||||||
chartOptions: {
|
chartOptions: {
|
||||||
scales: {
|
scales: {
|
||||||
|
|
@ -172,6 +177,9 @@ export default {
|
||||||
this.chartData.labels = Object.keys(statsData.views)
|
this.chartData.labels = Object.keys(statsData.views)
|
||||||
this.chartData.datasets[0].data = statsData.views
|
this.chartData.datasets[0].data = statsData.views
|
||||||
this.chartData.datasets[1].data = statsData.submissions
|
this.chartData.datasets[1].data = statsData.submissions
|
||||||
|
if (this.form.enable_partial_submissions) {
|
||||||
|
this.chartData.datasets[2].data = statsData.partial_submissions
|
||||||
|
}
|
||||||
this.isLoading = false
|
this.isLoading = false
|
||||||
}
|
}
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,14 @@
|
||||||
</VForm>
|
</VForm>
|
||||||
</div>
|
</div>
|
||||||
<div class="font-semibold flex gap-2">
|
<div class="font-semibold flex gap-2">
|
||||||
|
<USelectMenu
|
||||||
|
class="w-32"
|
||||||
|
v-if="form.enable_partial_submissions"
|
||||||
|
v-model="selectedStatus"
|
||||||
|
:options="statusList"
|
||||||
|
value-attribute="value"
|
||||||
|
option-attribute="label"
|
||||||
|
/>
|
||||||
<UButton
|
<UButton
|
||||||
size="sm"
|
size="sm"
|
||||||
color="white"
|
color="white"
|
||||||
|
|
@ -125,6 +133,12 @@ export default {
|
||||||
}),
|
}),
|
||||||
displayColumns: {},
|
displayColumns: {},
|
||||||
wrapColumns: {},
|
wrapColumns: {},
|
||||||
|
statusList: [
|
||||||
|
{ label: 'All', value: 'all' },
|
||||||
|
{ label: 'Submitted', value: 'completed' },
|
||||||
|
{ label: 'In Progress', value: 'partial' }
|
||||||
|
],
|
||||||
|
selectedStatus: 'all',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -147,7 +161,11 @@ export default {
|
||||||
filteredData() {
|
filteredData() {
|
||||||
if (!this.tableData) return []
|
if (!this.tableData) return []
|
||||||
|
|
||||||
const filteredData = clonedeep(this.tableData)
|
let filteredData = clonedeep(this.tableData)
|
||||||
|
|
||||||
|
if (this.selectedStatus !== 'all') {
|
||||||
|
filteredData = filteredData.filter((submission) => submission.status === this.selectedStatus)
|
||||||
|
}
|
||||||
|
|
||||||
if (this.searchForm.search === '' || this.searchForm.search === null) {
|
if (this.searchForm.search === '' || this.searchForm.search === null) {
|
||||||
return filteredData
|
return filteredData
|
||||||
|
|
@ -170,6 +188,9 @@ export default {
|
||||||
},
|
},
|
||||||
'searchForm.search'() {
|
'searchForm.search'() {
|
||||||
this.dataChanged()
|
this.dataChanged()
|
||||||
|
},
|
||||||
|
'selectedStatus'() {
|
||||||
|
this.dataChanged()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
v-if="hasPaymentBlock"
|
v-if="hasPaymentBlock"
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
title="You have a payment block in your form. so can't disable auto save"
|
title="Must be enabled with a payment block."
|
||||||
class="max-w-md"
|
class="max-w-md"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
@ -76,6 +76,31 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced Submission Settings -->
|
||||||
|
<h4 class="font-semibold mt-4 border-t pt-4">
|
||||||
|
Advanced Submission Options <pro-tag />
|
||||||
|
</h4>
|
||||||
|
<p class="text-gray-500 text-sm mb-4">
|
||||||
|
Configure advanced options for form submissions and data collection.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<ToggleSwitchInput
|
||||||
|
name="enable_partial_submissions"
|
||||||
|
:form="form"
|
||||||
|
help="Capture incomplete form submissions to analyze user drop-off points and collect partial data even when users don't complete the entire form."
|
||||||
|
>
|
||||||
|
<template #label>
|
||||||
|
<span class="text-sm">
|
||||||
|
Collect partial submissions
|
||||||
|
</span>
|
||||||
|
<ProTag
|
||||||
|
class="ml-1"
|
||||||
|
upgrade-modal-title="Upgrade to collect partial submissions"
|
||||||
|
upgrade-modal-description="Capture valuable data from incomplete form submissions. Analyze where users drop off and collect partial information even when they don't complete the entire form."
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ToggleSwitchInput>
|
||||||
|
|
||||||
<!-- Post-Submission Behavior -->
|
<!-- Post-Submission Behavior -->
|
||||||
<h4 class="font-semibold mt-4 border-t pt-4">
|
<h4 class="font-semibold mt-4 border-t pt-4">
|
||||||
After Submission <pro-tag
|
After Submission <pro-tag
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,18 @@
|
||||||
{{ col.name }}
|
{{ col.name }}
|
||||||
</p>
|
</p>
|
||||||
</resizable-th>
|
</resizable-th>
|
||||||
|
<th
|
||||||
|
v-if="hasStatus"
|
||||||
|
class="n-table-cell p-0 relative"
|
||||||
|
:class="{ 'border-r': hasActions }"
|
||||||
|
style="width: 100px"
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
class="bg-gray-50 dark:bg-notion-dark truncate sticky top-0 border-b border-gray-200 dark:border-gray-800 px-4 py-2 text-gray-500 font-semibold tracking-wider uppercase text-xs"
|
||||||
|
>
|
||||||
|
Status
|
||||||
|
</p>
|
||||||
|
</th>
|
||||||
<th
|
<th
|
||||||
v-if="hasActions"
|
v-if="hasActions"
|
||||||
class="n-table-cell p-0 relative"
|
class="n-table-cell p-0 relative"
|
||||||
|
|
@ -86,6 +98,19 @@
|
||||||
:value="row[col.id]"
|
:value="row[col.id]"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
<td
|
||||||
|
v-if="hasStatus"
|
||||||
|
class="n-table-cell border-gray-100 dark:border-gray-900 text-sm p-2 border-b"
|
||||||
|
:class="{ 'border-r': hasActions }"
|
||||||
|
style="width: 100px"
|
||||||
|
>
|
||||||
|
<UBadge
|
||||||
|
:label="row.status === 'partial' ? 'In Progress' : 'Submitted'"
|
||||||
|
:color="row.status === 'partial' ? 'yellow' : 'green'"
|
||||||
|
variant="soft"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
<td
|
<td
|
||||||
v-if="hasActions"
|
v-if="hasActions"
|
||||||
class="n-table-cell border-gray-100 dark:border-gray-900 text-sm p-2 border-b"
|
class="n-table-cell border-gray-100 dark:border-gray-900 text-sm p-2 border-b"
|
||||||
|
|
@ -229,6 +254,9 @@ export default {
|
||||||
hasActions() {
|
hasActions() {
|
||||||
return !this.workspace.is_readonly
|
return !this.workspace.is_readonly
|
||||||
},
|
},
|
||||||
|
hasStatus() {
|
||||||
|
return this.form.is_pro && (this.form.enable_partial_submissions ?? false)
|
||||||
|
},
|
||||||
formData() {
|
formData() {
|
||||||
return [...this.data].sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
|
return [...this.data].sort((a, b) => new Date(b.created_at) - new Date(a.created_at))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,17 @@ export const pendingSubmission = (form) => {
|
||||||
return pendingSubmission ? JSON.parse(pendingSubmission) : defaultValue
|
return pendingSubmission ? JSON.parse(pendingSubmission) : defaultValue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setSubmissionHash = (hash) => {
|
||||||
|
set({
|
||||||
|
...get(),
|
||||||
|
submission_hash: hash
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSubmissionHash = () => {
|
||||||
|
return get()?.submission_hash ?? null
|
||||||
|
}
|
||||||
|
|
||||||
const setTimer = (value) => {
|
const setTimer = (value) => {
|
||||||
if (import.meta.server) return
|
if (import.meta.server) return
|
||||||
useStorage(formPendingSubmissionTimerKey.value).value = value
|
useStorage(formPendingSubmissionTimerKey.value).value = value
|
||||||
|
|
@ -46,10 +57,13 @@ export const pendingSubmission = (form) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
formPendingSubmissionKey,
|
||||||
enabled,
|
enabled,
|
||||||
set,
|
set,
|
||||||
get,
|
get,
|
||||||
remove,
|
remove,
|
||||||
|
setSubmissionHash,
|
||||||
|
getSubmissionHash,
|
||||||
setTimer,
|
setTimer,
|
||||||
removeTimer,
|
removeTimer,
|
||||||
getTimer,
|
getTimer,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -63,16 +63,10 @@ async function handleCallback() {
|
||||||
|
|
||||||
// Get autoClose preference from the API response data
|
// Get autoClose preference from the API response data
|
||||||
const shouldAutoClose = data?.autoClose === true
|
const shouldAutoClose = data?.autoClose === true
|
||||||
console.log('[CallbackPage] Checking autoClose status from API data:', {
|
|
||||||
apiValue: data?.autoClose,
|
|
||||||
shouldAutoClose
|
|
||||||
})
|
|
||||||
|
|
||||||
alert.success('Account connected successfully.')
|
alert.success('Account connected successfully.')
|
||||||
|
|
||||||
// Close window if autoClose is set from API data, otherwise redirect
|
// Close window if autoClose is set from API data, otherwise redirect
|
||||||
if (shouldAutoClose) {
|
if (shouldAutoClose) {
|
||||||
console.log('[CallbackPage] Attempting window.close() based on API data.')
|
|
||||||
window.close()
|
window.close()
|
||||||
// Add a fallback check in case window.close is blocked
|
// Add a fallback check in case window.close is blocked
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -83,7 +77,6 @@ async function handleCallback() {
|
||||||
}
|
}
|
||||||
}, 500) // Check after 500ms
|
}, 500) // Check after 500ms
|
||||||
} else {
|
} else {
|
||||||
console.log('[CallbackPage] autoClose is false or not detected in API data, redirecting.')
|
|
||||||
router.push('/settings/connections')
|
router.push('/settings/connections')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue