diff --git a/config/forms.php b/config/forms.php index c2d434f0289..563b14b40e0 100644 --- a/config/forms.php +++ b/config/forms.php @@ -35,6 +35,18 @@ 'send_email_job' => \Statamic\Forms\SendEmail::class, + /* + |-------------------------------------------------------------------------- + | Incomplete Submissions + |-------------------------------------------------------------------------- + | + | Incomplete (partial) submissions are automatically deleted after a set + | number of days. Set this to null to prevent their automatic deletion. + | + */ + + 'delete_incomplete_submissions_after' => 7, + /* |-------------------------------------------------------------------------- | Exporters diff --git a/resources/js/components/forms/SubmissionListing.vue b/resources/js/components/forms/SubmissionListing.vue index 3ca80b6df83..da157c20b5d 100644 --- a/resources/js/components/forms/SubmissionListing.vue +++ b/resources/js/components/forms/SubmissionListing.vue @@ -9,12 +9,17 @@ :sort-direction="sortDirection" :preferences-prefix="preferencesPrefix" :filters="filters" + push-query > - + + + + diff --git a/resources/js/components/ui/Publish/Form.vue b/resources/js/components/ui/Publish/Form.vue index 8c156bf866a..35c4b22a99d 100644 --- a/resources/js/components/ui/Publish/Form.vue +++ b/resources/js/components/ui/Publish/Form.vue @@ -87,6 +87,9 @@ onUnmounted(() => saveKeyBinding.destroy()); diff --git a/src/Actions/MarkNotSpam.php b/src/Actions/MarkNotSpam.php new file mode 100644 index 00000000000..4e869fa8db0 --- /dev/null +++ b/src/Actions/MarkNotSpam.php @@ -0,0 +1,47 @@ +isSpam(); + } + + public function authorize($user, $submission) + { + return $user->can('delete', $submission); + } + + public function confirmationText() + { + /** @translation */ + return 'Are you sure you want to mark this submission as not spam?|Are you sure you want to mark these :count submissions as not spam?'; + } + + public function buttonText() + { + /** @translation */ + return 'Mark As Not Spam|Mark :count as Not Spam'; + } + + public function run($submissions, $values) + { + $submissions->each->complete(); + + return trans_choice('Submission marked as not spam|Submissions marked as not spam', $submissions->count()); + } +} diff --git a/src/Forms/Submission.php b/src/Forms/Submission.php index 1ce97715656..dba1546141c 100644 --- a/src/Forms/Submission.php +++ b/src/Forms/Submission.php @@ -19,6 +19,7 @@ use Statamic\Facades\Asset; use Statamic\Facades\File; use Statamic\Facades\FormSubmission; +use Statamic\Facades\Site; use Statamic\Facades\Stache; use Statamic\Forms\Uploaders\AssetsUploader; use Statamic\Forms\Uploaders\FilesUploader; @@ -114,6 +115,25 @@ public function date() return Carbon::createFromTimestamp($this->id()); } + public function isIncomplete(): bool + { + return (bool) $this->get('incomplete'); + } + + public function isSpam(): bool + { + return (bool) $this->get('spam'); + } + + public function status(): string + { + return match (true) { + $this->isSpam() => 'spam', + $this->isIncomplete() => 'incomplete', + default => 'complete', + }; + } + /** * Upload files and return asset IDs. * @@ -152,6 +172,10 @@ public function save() { $isNew = is_null($this->form()->submission($this->id())); + // Incomplete and spam submissions are stored but skip the Creating/Created + // events so listeners never receive an incomplete submission. + $isWithheld = $this->isIncomplete() || $this->isSpam(); + $withEvents = $this->withEvents; $this->withEvents = true; @@ -159,7 +183,7 @@ public function save() $this->afterSaveCallbacks = []; if ($withEvents) { - if ($isNew && SubmissionCreating::dispatch($this) === false) { + if ($isNew && ! $isWithheld && SubmissionCreating::dispatch($this) === false) { return false; } @@ -175,7 +199,7 @@ public function save() } if ($withEvents) { - if ($isNew) { + if ($isNew && ! $isWithheld) { SubmissionCreated::dispatch($this); } @@ -183,6 +207,28 @@ public function save() } } + public function complete() + { + $existed = ! is_null($this->form()->submission($this->id())); + + $this->remove('incomplete')->remove('spam'); + + if ($this->form()->store()) { + $this->save(); + + // A promoted incomplete already existed, so save() won't fire the created + // event. We dispatch it here so completion always emits it once. + if ($existed) { + SubmissionCreated::dispatch($this); + } + } else { + SubmissionCreated::dispatch($this); + } + + // TODO: Use $this->site() here when we add the "site" key to submissions. + SendEmails::dispatch($this, Site::default()); + } + public function deleteQuietly() { $this->withEvents = false; diff --git a/src/Http/Controllers/CP/Forms/FormSubmissionsController.php b/src/Http/Controllers/CP/Forms/FormSubmissionsController.php index 5eecb60a03a..e91c8ee77fc 100644 --- a/src/Http/Controllers/CP/Forms/FormSubmissionsController.php +++ b/src/Http/Controllers/CP/Forms/FormSubmissionsController.php @@ -36,6 +36,7 @@ public function index(FilteredRequest $request, $form) $columns = $form ->blueprint() ->columns() + ->prepend(Column::make('status'), 'status') ->prepend(Column::make('datestamp'), 'datestamp') ->setPreferred("forms.{$form->handle()}.columns") ->rejectUnlisted() @@ -131,6 +132,7 @@ public function show($form, $submission) 'form' => $form, 'id' => $submission->id(), 'formTitle' => $form->title(), + 'status' => $submission->status(), 'date' => $submission->date()->toIso8601String(), 'blueprint' => $blueprint->toPublishArray(), 'values' => $fields->values(), diff --git a/src/Http/Controllers/FormController.php b/src/Http/Controllers/FormController.php index 1b151be7c80..477d9483c71 100644 --- a/src/Http/Controllers/FormController.php +++ b/src/Http/Controllers/FormController.php @@ -8,13 +8,11 @@ use Illuminate\Validation\ValidationException; use Statamic\Contracts\Forms\Submission; use Statamic\Events\FormSubmitted; -use Statamic\Events\SubmissionCreated; use Statamic\Exceptions\SilentFormFailureException; use Statamic\Facades\Asset; use Statamic\Facades\Form; use Statamic\Facades\Site; use Statamic\Forms\Exceptions\FileContentTypeRequiredException; -use Statamic\Forms\SendEmails; use Statamic\Http\Requests\FrontendFormRequest; use Statamic\Support\Arr; use Statamic\Support\Str; @@ -70,16 +68,7 @@ public function submit(FrontendFormRequest $request, $form) return $this->formSuccess($params, $submission, true); } - if ($form->store()) { - $submission->save(); - } else { - // When the submission is saved, this same created event will be dispatched. - // We'll also fire it here if submissions are not configured to be stored - // so that developers may continue to listen and modify it as needed. - SubmissionCreated::dispatch($submission); - } - - SendEmails::dispatch($submission, $site); + $submission->complete($site); return $this->formSuccess($params, $submission); } diff --git a/src/Http/Resources/CP/Submissions/ListedSubmission.php b/src/Http/Resources/CP/Submissions/ListedSubmission.php index 2f81734d40e..323b6995e2c 100644 --- a/src/Http/Resources/CP/Submissions/ListedSubmission.php +++ b/src/Http/Resources/CP/Submissions/ListedSubmission.php @@ -33,6 +33,7 @@ public function toArray($request) $this->merge($this->values([ 'datestamp' => $this->resource->date()->tz(config('app.timezone'))->toIso8601String(), ])), + 'status' => $this->resource->status(), 'url' => cp_route('forms.submissions.show', [$form->handle(), $this->resource->id()]), 'deleteable' => User::current()->can('delete', $this->resource), ]; @@ -42,6 +43,11 @@ protected function values($extra = []) { return $this->columns->mapWithKeys(function ($column) use ($extra) { $key = $column->field; + + if ($key === 'status') { + return ['status' => $this->resource->status()]; + } + $value = $extra[$key] ?? $this->resource->get($key); if (! $field = $this->blueprint->field($key)) { diff --git a/src/Http/Resources/CP/Submissions/Submissions.php b/src/Http/Resources/CP/Submissions/Submissions.php index 3b51ff3106c..fd9e89386f6 100644 --- a/src/Http/Resources/CP/Submissions/Submissions.php +++ b/src/Http/Resources/CP/Submissions/Submissions.php @@ -6,6 +6,8 @@ use Statamic\CP\Column; use Statamic\Http\Resources\CP\Concerns\HasRequestedColumns; +use function Statamic\trans as __; + class Submissions extends ResourceCollection { use HasRequestedColumns; @@ -35,6 +37,16 @@ private function setColumns() ->columns() ->ensurePrepended(Column::make('datestamp')->label('Date')); + $status = Column::make('status') + ->label(__('Status')) + ->listable(true) + ->visible(true) + ->defaultVisibility(true) + ->defaultOrder($columns->count() + 1) + ->sortable(false); + + $columns->put('status', $status); + if ($key = $this->columnPreferenceKey) { $columns->setPreferred($key); } diff --git a/src/Jobs/DeleteIncompleteFormSubmissions.php b/src/Jobs/DeleteIncompleteFormSubmissions.php new file mode 100644 index 00000000000..75813ad0be5 --- /dev/null +++ b/src/Jobs/DeleteIncompleteFormSubmissions.php @@ -0,0 +1,30 @@ +subDays($days); + + FormSubmission::query() + ->where('incomplete', true) + ->where('date', '<', $threshold) + ->get() + ->each + ->delete(); + } +} diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index ea221a1bea6..a16471f21d6 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -20,6 +20,7 @@ use Statamic\Facades\Token; use Statamic\Facades\User; use Statamic\Fields\FieldsetRecursionStack; +use Statamic\Jobs\DeleteIncompleteFormSubmissions; use Statamic\Jobs\HandleEntrySchedule; use Statamic\Notifications\ElevatedSessionVerificationCode; use Statamic\Sites\Sites; @@ -141,6 +142,7 @@ public function boot() $this->registerElevatedSessionMacros(); $this->app->make(Schedule::class)->job(HandleEntrySchedule::class)->everyMinute(); + $this->app->make(Schedule::class)->job(DeleteIncompleteFormSubmissions::class)->daily(); } public function register() diff --git a/src/Providers/ExtensionServiceProvider.php b/src/Providers/ExtensionServiceProvider.php index 51f32fd4e09..0616624794d 100644 --- a/src/Providers/ExtensionServiceProvider.php +++ b/src/Providers/ExtensionServiceProvider.php @@ -34,6 +34,7 @@ class ExtensionServiceProvider extends ServiceProvider Actions\Delete::class, Actions\DeleteFakeSubmissions::class, Actions\DeleteMultisiteEntry::class, + Actions\MarkNotSpam::class, Actions\DisableTwoFactorAuthentication::class, Actions\DownloadAsset::class, Actions\DownloadAssetFolder::class, @@ -200,6 +201,7 @@ class ExtensionServiceProvider extends ServiceProvider Scopes\Filters\Fields::class, Scopes\Filters\Blueprint::class, Scopes\Filters\Status::class, + Scopes\Filters\SubmissionStatus::class, Scopes\Filters\Site::class, Scopes\Filters\UserRole::class, Scopes\Filters\UserGroup::class, diff --git a/src/Query/Scopes/Filters/SubmissionStatus.php b/src/Query/Scopes/Filters/SubmissionStatus.php new file mode 100644 index 00000000000..a7af9043cc0 --- /dev/null +++ b/src/Query/Scopes/Filters/SubmissionStatus.php @@ -0,0 +1,60 @@ + [ + 'type' => 'radio', + 'options' => $this->options()->all(), + ], + ]; + } + + public function autoApply() + { + return ['status' => 'complete']; + } + + public function apply($query, $values) + { + match ($values['status']) { + 'incomplete' => $query->where('incomplete', true), + 'spam' => $query->where('spam', true), + default => $query->where('incomplete', '!=', true)->where('spam', '!=', true), + }; + } + + public function badge($values) + { + return $this->options()->get($values['status']); + } + + public function visibleTo($key) + { + return $key === 'form-submissions'; + } + + protected function options() + { + return collect([ + 'complete' => __('Complete'), + 'incomplete' => __('Incomplete'), + 'spam' => __('Spam'), + ]); + } +} diff --git a/tests/Actions/MarkNotSpamTest.php b/tests/Actions/MarkNotSpamTest.php new file mode 100644 index 00000000000..e3dac458411 --- /dev/null +++ b/tests/Actions/MarkNotSpamTest.php @@ -0,0 +1,55 @@ +fakeStacheDirectory.'/forms'; + } + + #[Test] + public function it_is_only_visible_to_spam_submissions() + { + $form = tap(Form::make('contact'))->save(); + + $action = new MarkNotSpam; + + $this->assertFalse($action->visibleTo($form->makeSubmission())); + $this->assertFalse($action->visibleTo($form->makeSubmission()->set('incomplete', true))); + $this->assertTrue($action->visibleTo($form->makeSubmission()->set('spam', true))); + } + + #[Test] + public function it_removes_the_spam_key_and_dispatches_relevant_events() + { + Bus::fake(); + Event::fake([SubmissionCreated::class]); + + $form = tap(Form::make('contact'))->save(); + $submission = $form->makeSubmission()->set('spam', true); + $submission->save(); + + (new MarkNotSpam)->run(collect([$submission]), []); + + $this->assertFalse($submission->isSpam()); + + Event::assertDispatched(SubmissionCreated::class); + Bus::assertDispatched(SendEmails::class); + } +} diff --git a/tests/Forms/DeleteIncompleteFormSubmissionsTest.php b/tests/Forms/DeleteIncompleteFormSubmissionsTest.php new file mode 100644 index 00000000000..ed25ac2b853 --- /dev/null +++ b/tests/Forms/DeleteIncompleteFormSubmissionsTest.php @@ -0,0 +1,89 @@ +fakeStacheDirectory.'/forms'; + } + + #[Test] + public function it_deletes_incomplete_submissions_older_than_the_configured_threshold() + { + config(['statamic.forms.delete_incomplete_submissions_after' => 7]); + + $form = tap(Form::make('contact'))->save(); + + Carbon::setTestNow('2025-06-01 12:00:00'); + $oldIncomplete = tap($form->makeSubmission()->set('incomplete', true))->save(); + + Carbon::setTestNow('2025-06-02 12:00:00'); + $oldComplete = tap($form->makeSubmission())->save(); + + Carbon::setTestNow('2025-06-14 12:00:00'); + $recentIncomplete = tap($form->makeSubmission()->set('incomplete', true))->save(); + + Carbon::setTestNow('2025-06-15 12:00:00'); + + (new DeleteIncompleteFormSubmissions)->handle(); + + $this->assertNull($form->submission($oldIncomplete->id())); + $this->assertNotNull($form->submission($recentIncomplete->id())); + $this->assertNotNull($form->submission($oldComplete->id())); + } + + #[Test] + public function it_only_deletes_incomplete_submissions_never_complete_or_spam() + { + config(['statamic.forms.delete_incomplete_submissions_after' => 7]); + + $form = tap(Form::make('contact'))->save(); + + Carbon::setTestNow('2025-06-01 12:00:00'); + $incomplete = tap($form->makeSubmission()->set('incomplete', true))->save(); + + Carbon::setTestNow('2025-06-02 12:00:00'); + $complete = tap($form->makeSubmission())->save(); + + Carbon::setTestNow('2025-06-03 12:00:00'); + $spam = tap($form->makeSubmission()->set('spam', true))->save(); + + Carbon::setTestNow('2025-06-30 12:00:00'); + + (new DeleteIncompleteFormSubmissions)->handle(); + + $this->assertNull($form->submission($incomplete->id())); + $this->assertNotNull($form->submission($complete->id())); + $this->assertNotNull($form->submission($spam->id())); + } + + #[Test] + public function it_does_not_delete_anything_when_disabled() + { + config(['statamic.forms.delete_incomplete_submissions_after' => null]); + + $form = tap(Form::make('contact'))->save(); + + Carbon::setTestNow('2025-06-01 12:00:00'); + $incomplete = tap($form->makeSubmission()->set('incomplete', true))->save(); + + Carbon::setTestNow('2025-06-30 12:00:00'); + + (new DeleteIncompleteFormSubmissions)->handle(); + + $this->assertNotNull($form->submission($incomplete->id())); + } +} diff --git a/tests/Forms/SubmissionTest.php b/tests/Forms/SubmissionTest.php index d19fde2c5d3..f8515e7cbc6 100644 --- a/tests/Forms/SubmissionTest.php +++ b/tests/Forms/SubmissionTest.php @@ -4,6 +4,7 @@ use Carbon\Carbon; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Bus; use Illuminate\Support\Facades\Event; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -13,6 +14,8 @@ use Statamic\Events\SubmissionSaved; use Statamic\Events\SubmissionSaving; use Statamic\Facades\Form; +use Statamic\Facades\Site; +use Statamic\Forms\SendEmails; use Tests\PreventSavingStacheItemsToDisk; use Tests\TestCase; @@ -240,6 +243,129 @@ public function it_deletes_quietly() $this->assertTrue($return); } + #[Test] + public function it_determines_its_status() + { + $form = tap(Form::make('contact_us'))->save(); + + $submitted = $form->makeSubmission(); + $this->assertFalse($submitted->isIncomplete()); + $this->assertFalse($submitted->isSpam()); + $this->assertEquals('complete', $submitted->status()); + + $incomplete = $form->makeSubmission()->set('incomplete', true); + $this->assertTrue($incomplete->isIncomplete()); + $this->assertFalse($incomplete->isSpam()); + $this->assertEquals('incomplete', $incomplete->status()); + + $spam = $form->makeSubmission()->set('spam', true); + $this->assertTrue($spam->isSpam()); + $this->assertFalse($spam->isIncomplete()); + $this->assertEquals('spam', $spam->status()); + } + + #[Test] + #[DataProvider('withheldStatusProvider')] + public function it_does_not_dispatch_creation_events_when_saving_a_withheld_submission(string $status) + { + Event::fake(); + + $form = tap(Form::make('contact_us'))->save(); + + $submission = $form->makeSubmission()->set($status, true); + $submission->save(); + + // Creation events shouldn't be dispatched. + Event::assertNotDispatched(SubmissionCreating::class); + Event::assertNotDispatched(SubmissionCreated::class); + + // But, saving events should. + Event::assertDispatched(SubmissionSaving::class); + Event::assertDispatched(SubmissionSaved::class); + } + + public static function withheldStatusProvider(): array + { + return [ + 'incomplete' => ['incomplete'], + 'spam' => ['spam'], + ]; + } + + #[Test] + public function created_event_is_not_automatically_dispatched_when_removing_the_incomplete_key() + { + $form = tap(Form::make('contact_us'))->save(); + + $submission = $form->makeSubmission()->set('incomplete', true); + $submission->save(); + + Event::fake(); + + // Removing the incomplete key turns it into a "real" submission, but because + // the record already exists, save() alone won't dispatch Created. This is why + // complete() dispatches it explicitly (covered by the test below). + $submission->remove('incomplete'); + $submission->save(); + + Event::assertNotDispatched(SubmissionCreating::class); + Event::assertNotDispatched(SubmissionCreated::class); + Event::assertDispatched(SubmissionSaved::class); + } + + #[Test] + public function completing_a_new_submission_dispatches_created_event_once() + { + Bus::fake(); + Event::fake([SubmissionCreated::class]); + + $form = tap(Form::make('contact_us'))->save(); + $submission = $form->makeSubmission(); + + $submission->complete(Site::default()); + + Event::assertDispatched(SubmissionCreated::class, 1); + Bus::assertDispatched(SendEmails::class, 1); + + $this->assertNotNull($form->submission($submission->id())); + } + + #[Test] + public function completing_an_incomplete_or_spam_submission_removes_the_status_key_and_dispatches_events() + { + $form = tap(Form::make('contact_us'))->save(); + $submission = tap($form->makeSubmission()->set('incomplete', true)->set('spam', true))->save(); + + Bus::fake(); + Event::fake([SubmissionCreated::class, SubmissionCreating::class]); + + $submission->complete(Site::default()); + + $this->assertFalse($submission->isIncomplete()); + $this->assertFalse($submission->isSpam()); + + // Submission already exists, so save() won't dispatch the Created event, complete() will. + Event::assertDispatched(SubmissionCreated::class, 1); + Event::assertNotDispatched(SubmissionCreating::class); + Bus::assertDispatched(SendEmails::class, 1); + } + + #[Test] + public function completing_a_submission_for_a_non_storing_form_still_dispatches_the_created_event() + { + Bus::fake(); + Event::fake([SubmissionCreated::class]); + + $form = tap(Form::make('contact_us')->store(false))->save(); + $submission = $form->makeSubmission(); + + $submission->complete(Site::default()); + + Event::assertDispatched(SubmissionCreated::class, 1); + Bus::assertDispatched(SendEmails::class, 1); + $this->assertNull($form->submission($submission->id())); + } + #[Test] public function it_clones_internal_collections() {