diff --git a/src/Forms/Connectors/Webhook.php b/src/Forms/Connectors/Webhook.php index 88f635fc962..759a557f2bb 100644 --- a/src/Forms/Connectors/Webhook.php +++ b/src/Forms/Connectors/Webhook.php @@ -105,7 +105,7 @@ private function examplePayload(): string $latestSubmission = null; if (User::current()->can('viewSubmissions', $form)) { - $latestSubmission = $form->querySubmissions()->whereNull('partial')->orderBy('date', 'desc')->first(); + $latestSubmission = $form->querySubmissions()->whereStatus('finalized')->orderBy('date', 'desc')->first(); } return json_encode([ diff --git a/src/Forms/Fieldtype.php b/src/Forms/Fieldtype.php index dd12b54e860..337865bcf6f 100644 --- a/src/Forms/Fieldtype.php +++ b/src/Forms/Fieldtype.php @@ -130,7 +130,7 @@ public function getIndexItems($request) return [ 'id' => $form->handle(), 'title' => __($form->title()), - 'submissions' => $form->submissions()->count(), + 'submissions' => $form->querySubmissions()->whereStatus('finalized')->count(), ]; }; diff --git a/src/Forms/Form.php b/src/Forms/Form.php index c07f5d71e71..fbf2108fbc3 100644 --- a/src/Forms/Form.php +++ b/src/Forms/Form.php @@ -585,7 +585,7 @@ private function submissionLimitReached(): bool private function submissionCount(): int { - $query = $this->querySubmissions()->whereNull('partial'); + $query = $this->querySubmissions()->whereStatus('finalized'); if ($start = $this->submissionLimitPeriodStart()) { $query->where('date', '>=', $start); diff --git a/src/Forms/Tags.php b/src/Forms/Tags.php index f35a8f4d994..0b004863074 100644 --- a/src/Forms/Tags.php +++ b/src/Forms/Tags.php @@ -272,6 +272,7 @@ public function submissions() { $query = $this->form()->querySubmissions(); + $this->queryStatus($query); $this->queryConditions($query); $this->queryScopes($query); $this->queryOrderBys($query); @@ -279,6 +280,15 @@ public function submissions() return $this->output($this->results($query)); } + protected function queryStatus($query) + { + if ($this->isQueryingCondition('status') || $this->isQueryingCondition('partial')) { + return; + } + + $query->whereStatus('finalized'); + } + /** * Get the sort order for a collection. * diff --git a/src/Http/Controllers/CP/Forms/EmailConnectionPreviewController.php b/src/Http/Controllers/CP/Forms/EmailConnectionPreviewController.php index aba0c506bc4..70395b07168 100644 --- a/src/Http/Controllers/CP/Forms/EmailConnectionPreviewController.php +++ b/src/Http/Controllers/CP/Forms/EmailConnectionPreviewController.php @@ -60,7 +60,7 @@ private function latestSubmission(Form $form): ?Submission return null; } - return $form->querySubmissions()->whereNull('partial')->orderBy('date', 'desc')->first(); + return $form->querySubmissions()->whereStatus('finalized')->orderBy('date', 'desc')->first(); } private function sampleSubmission(Form $form, FakeSubmissionGenerator $generator): Submission diff --git a/src/Http/Controllers/CP/Forms/FormsController.php b/src/Http/Controllers/CP/Forms/FormsController.php index 57c6f46c04a..febc19fe101 100644 --- a/src/Http/Controllers/CP/Forms/FormsController.php +++ b/src/Http/Controllers/CP/Forms/FormsController.php @@ -52,7 +52,7 @@ public function index(Request $request) 'id' => $form->handle(), 'title' => __($form->title()), 'status' => $form->status(), - 'submissions' => $canViewSubmissions ? $form->querySubmissions()->where('site', Site::selected())->whereNull('partial')->count() : null, + 'submissions' => $canViewSubmissions ? $form->querySubmissions()->where('site', Site::selected())->whereStatus('finalized')->count() : null, 'connections' => $canEdit ? FormConnector::all()->sum(fn ($connector) => $connector->forForm($form)->count() ?? 0) : null, 'show_url' => $form->showUrl(), 'submissions_url' => $form->submissionsUrl(), diff --git a/src/Jobs/DeletePartialFormSubmissions.php b/src/Jobs/DeletePartialFormSubmissions.php index c3a4be56d41..e7cb08609a6 100644 --- a/src/Jobs/DeletePartialFormSubmissions.php +++ b/src/Jobs/DeletePartialFormSubmissions.php @@ -21,7 +21,7 @@ public function handle(): void $threshold = now()->subDays($days); FormSubmission::query() - ->where('partial', true) + ->whereStatus('partial') ->where('date', '<', $threshold) ->get() ->each->delete(); diff --git a/src/Query/Scopes/Filters/SubmissionStatus.php b/src/Query/Scopes/Filters/SubmissionStatus.php index 87920bc44e6..65799a2f3d4 100644 --- a/src/Query/Scopes/Filters/SubmissionStatus.php +++ b/src/Query/Scopes/Filters/SubmissionStatus.php @@ -32,10 +32,7 @@ public function autoApply() public function apply($query, $values) { - match ($values['status']) { - 'partial' => $query->where('partial', true), - default => $query->where('partial', '!=', true), - }; + $query->whereStatus($values['status']); } public function badge($values) diff --git a/src/Stache/Query/SubmissionQueryBuilder.php b/src/Stache/Query/SubmissionQueryBuilder.php index 0505fa56830..1720cc27852 100644 --- a/src/Stache/Query/SubmissionQueryBuilder.php +++ b/src/Stache/Query/SubmissionQueryBuilder.php @@ -33,6 +33,16 @@ public function whereIn($column, $values, $boolean = 'and') return parent::whereIn($column, $values, $boolean); } + public function whereStatus(string $status) + { + return match ($status) { + 'any' => $this, + 'finalized' => $this->where('partial', '!=', true), + 'partial' => $this->where('partial', true), + default => throw new \Exception("Invalid status [$status]"), + }; + } + public function orderBy($column, $direction = 'asc') { if ($column === 'datestamp') { diff --git a/tests/Feature/Forms/ExportSubmissionsTest.php b/tests/Feature/Forms/ExportSubmissionsTest.php index 95c5f213831..aeea6444ae2 100644 --- a/tests/Feature/Forms/ExportSubmissionsTest.php +++ b/tests/Feature/Forms/ExportSubmissionsTest.php @@ -79,6 +79,49 @@ public function it_exports_only_the_requested_columns() ->assertDontSee('email'); } + #[Test] + public function it_exports_all_submissions_including_partials() + { + $form = $this->createFormWithSubmissions(); + FormSubmission::make()->form($form)->id('1700000004')->data(['name' => 'Delta'])->asPartial()->save(); + + $response = $this + ->actingAs(tap(User::make()->makeSuper())->save()) + ->export($form, 'csv') + ->assertOk(); + + $this->assertEqualsCanonicalizing(['Alice', 'Bravo', 'Charlie', 'Delta'], $this->namesFromCsv($response->getContent())); + } + + #[Test] + public function it_exports_all_submissions_including_partials_when_sorted() + { + $form = $this->createFormWithSubmissions(); + FormSubmission::make()->form($form)->id('1700000004')->data(['name' => 'Delta'])->asPartial()->save(); + + $response = $this + ->actingAs(tap(User::make()->makeSuper())->save()) + ->export($form, 'csv', ['sort' => 'name', 'order' => 'asc']) + ->assertOk(); + + $this->assertEquals(['Alice', 'Bravo', 'Charlie', 'Delta'], $this->namesFromCsv($response->getContent())); + } + + #[Test] + public function it_exports_submissions_filtered_by_status() + { + $form = $this->createFormWithSubmissions(); + FormSubmission::make()->form($form)->id('1700000004')->data(['name' => 'Delta'])->asPartial()->save(); + + $this->actingAs(tap(User::make()->makeSuper())->save()); + + $finalized = $this->export($form, 'csv', ['sort' => 'name', 'filters' => base64_encode(json_encode(['submission_status' => ['status' => 'finalized']]))]); + $this->assertEquals(['Alice', 'Bravo', 'Charlie'], $this->namesFromCsv($finalized->getContent())); + + $partial = $this->export($form, 'csv', ['sort' => 'name', 'filters' => base64_encode(json_encode(['submission_status' => ['status' => 'partial']]))]); + $this->assertEquals(['Delta'], $this->namesFromCsv($partial->getContent())); + } + private function createFormWithSubmissions() { $blueprint = Blueprint::makeFromFields(['name' => ['type' => 'text']]); diff --git a/tests/Feature/Forms/ViewFormListingTest.php b/tests/Feature/Forms/ViewFormListingTest.php index 1f2aa29a7e7..09e1fee5247 100644 --- a/tests/Feature/Forms/ViewFormListingTest.php +++ b/tests/Feature/Forms/ViewFormListingTest.php @@ -282,4 +282,23 @@ private function makeSubmission($form, $site = null) $submission->data(['name' => 'John Doe']); $submission->save(); } + + #[Test] + public function it_excludes_partial_submissions_from_the_submission_count() + { + $this->setTestRoles(['test' => ['access cp', 'view form submissions']]); + $user = tap(User::make()->assignRole('test'))->save(); + $form = tap(Form::make('test'))->save(); + $this->makeSubmission($form); + $form->makeSubmission()->asPartial()->save(); + + $this + ->actingAs($user) + ->get(cp_route('forms.index')) + ->assertSuccessful() + ->assertInertia(fn ($page) => $page + ->component('forms/Index') + ->where('forms.0.submissions', 1) + ); + } } diff --git a/tests/Forms/FieldtypeTest.php b/tests/Forms/FieldtypeTest.php new file mode 100644 index 00000000000..6256c50d951 --- /dev/null +++ b/tests/Forms/FieldtypeTest.php @@ -0,0 +1,30 @@ +actingAs(tap(User::make()->makeSuper())->save()); + + $form = tap(Form::make('test'))->save(); + $form->makeSubmission()->save(); + $form->makeSubmission()->asPartial()->save(); + + $items = (new Fieldtype)->getIndexItems(new Request(['paginate' => false])); + + $this->assertEquals(1, $items->firstWhere('id', 'test')['submissions']); + } +} diff --git a/tests/Forms/SubmissionQueryBuilderTest.php b/tests/Forms/SubmissionQueryBuilderTest.php index f7dcadf0a0a..3ee06e43c9f 100644 --- a/tests/Forms/SubmissionQueryBuilderTest.php +++ b/tests/Forms/SubmissionQueryBuilderTest.php @@ -461,4 +461,62 @@ public function submissions_are_found_using_offset() $submissions = FormSubmission::query()->offset(1)->get(); $this->assertEquals(['b', 'c'], $submissions->map->get('id')->all()); } + + private function createSubmissionsWithStatuses() + { + $form = tap(Form::make('test'))->save(); + FormSubmission::make()->form($form)->data(['ref' => 'a'])->save(); + FormSubmission::make()->form($form)->data(['ref' => 'b', 'partial' => true])->save(); + FormSubmission::make()->form($form)->data(['ref' => 'c'])->save(); + FormSubmission::make()->form($form)->data(['ref' => 'd', 'partial' => true])->save(); + + return $form; + } + + private function ids($submissions) + { + return $submissions->map->get('ref')->sort()->values()->all(); + } + + #[Test] + public function it_filters_by_status() + { + $this->createSubmissionsWithStatuses(); + + $this->assertEquals(['a', 'c'], $this->ids(FormSubmission::query()->whereStatus('finalized')->get())); + $this->assertEquals(['b', 'd'], $this->ids(FormSubmission::query()->whereStatus('partial')->get())); + $this->assertEquals(['a', 'b', 'c', 'd'], $this->ids(FormSubmission::query()->whereStatus('any')->get())); + } + + #[Test] + public function it_throws_an_exception_for_an_invalid_status() + { + $this->expectExceptionMessage('Invalid status [foo]'); + + FormSubmission::query()->whereStatus('foo'); + } + + #[Test] + public function the_repository_query_does_not_filter_by_status_by_default() + { + $form = $this->createSubmissionsWithStatuses(); + + $this->assertEquals(['a', 'b', 'c', 'd'], $this->ids(FormSubmission::query()->get())); + $this->assertEquals(['a', 'b', 'c', 'd'], $this->ids(FormSubmission::all())); + $this->assertEquals(['a', 'b', 'c', 'd'], $this->ids(FormSubmission::whereForm('test'))); + + $partial = FormSubmission::query()->where('partial', true)->first(); + $this->assertEquals($partial, FormSubmission::find($partial->id())); + $this->assertEquals($partial, $form->submission($partial->id())); + } + + #[Test] + public function the_form_query_does_not_filter_by_status_by_default() + { + $form = $this->createSubmissionsWithStatuses(); + + $this->assertEquals(['a', 'b', 'c', 'd'], $this->ids($form->querySubmissions()->get())); + $this->assertEquals(['a', 'b', 'c', 'd'], $this->ids($form->submissions())); + $this->assertEquals(['b', 'd'], $this->ids($form->querySubmissions()->whereStatus('partial')->get())); + } } diff --git a/tests/Forms/SubmissionTest.php b/tests/Forms/SubmissionTest.php index 872e7a4bdf7..ebebc511392 100644 --- a/tests/Forms/SubmissionTest.php +++ b/tests/Forms/SubmissionTest.php @@ -16,6 +16,7 @@ use Statamic\Events\SubmissionSaved; use Statamic\Events\SubmissionSaving; use Statamic\Facades\Form; +use Statamic\Facades\FormSubmission; use Statamic\Facades\Site; use Statamic\Forms\Connectors\Webhooks\SendWebhook; use Statamic\Forms\CreateAssetsFromFileUploads; @@ -566,4 +567,17 @@ public function it_clones_internal_collections() $this->assertEquals('A', $submission->getSupplement('bar')); $this->assertEquals('B', $clone->getSupplement('bar')); } + + #[Test] + public function deleting_the_form_deletes_partial_submissions_too() + { + $form = tap(Form::make('contact_us'))->save(); + $finalized = tap($form->makeSubmission())->save(); + $partial = tap($form->makeSubmission()->asPartial())->save(); + + $form->delete(); + + $this->assertNull(FormSubmission::find($finalized->id())); + $this->assertNull(FormSubmission::find($partial->id())); + } } diff --git a/tests/Tags/Form/FormSubmissionsTest.php b/tests/Tags/Form/FormSubmissionsTest.php index 436310fd673..45fd3386941 100644 --- a/tests/Tags/Form/FormSubmissionsTest.php +++ b/tests/Tags/Form/FormSubmissionsTest.php @@ -3,6 +3,7 @@ namespace Tests\Tags\Form; use PHPUnit\Framework\Attributes\Test; +use Statamic\Facades\Form; class FormSubmissionsTest extends FormTestCase { @@ -37,4 +38,31 @@ public function it_renders_submissions() $this->assertStringContainsString('Email: san@holo.com
Message: foo', $output); $this->assertStringContainsString('Email: foba@bett.com
Message: bar', $output); } + + #[Test] + public function it_only_renders_finalized_submissions_by_default() + { + $this->createSubmissions(); + + $output = $this->tag('{{ form:submissions in="contact" }}[{{ message }}]{{ /form:submissions }}'); + + $this->assertEquals('[finalized]', trim($output)); + } + + #[Test] + public function it_renders_submissions_filtered_by_status() + { + $this->createSubmissions(); + + $this->assertEquals('[partial]', trim($this->tag('{{ form:submissions in="contact" status:is="partial" }}[{{ message }}]{{ /form:submissions }}'))); + $this->assertEquals('[finalized]', trim($this->tag('{{ form:submissions in="contact" status:is="finalized" }}[{{ message }}]{{ /form:submissions }}'))); + $this->assertEquals('[finalized][partial]', trim($this->tag('{{ form:submissions in="contact" status:is="any" sort="message" }}[{{ message }}]{{ /form:submissions }}'))); + } + + private function createSubmissions() + { + $form = Form::find('contact'); + $form->makeSubmission()->data(['email' => 'san@holo.com', 'message' => 'finalized'])->save(); + $form->makeSubmission()->data(['email' => 'foba@bett.com', 'message' => 'partial'])->asPartial()->save(); + } }