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