diff --git a/lang/en/messages.php b/lang/en/messages.php index 960cbf279e8..61979880a8d 100644 --- a/lang/en/messages.php +++ b/lang/en/messages.php @@ -153,7 +153,7 @@ 'form_create_description' => 'Get started by creating your first form.', 'form_builder' => 'Form Builder', 'form_builder_empty_inspector' => 'Drag fields onto the form to add them. Click a field, section, or page to edit its settings here.', - 'form_export_filtered_description' => 'Exports submissions with current filters and visible columns.', + 'form_export_filtered_description' => 'Exports only the submissions matching the current search, filters and sort order.', 'form_fake_submissions_deleted' => 'Deleted :count fake submission.|Deleted :count fake submissions.', 'form_fake_submissions_delete_failed' => 'Fake submissions could not be deleted.', 'form_fake_submissions_form_not_found' => 'Form could not be found for deleting fake submissions.', diff --git a/resources/js/components/forms/ExportSubmissionsModal.vue b/resources/js/components/forms/ExportSubmissionsModal.vue new file mode 100644 index 00000000000..9fc857ab470 --- /dev/null +++ b/resources/js/components/forms/ExportSubmissionsModal.vue @@ -0,0 +1,112 @@ + + + diff --git a/resources/js/pages/forms/submissions/Index.vue b/resources/js/pages/forms/submissions/Index.vue index 71dcda38e12..146ceb8f759 100644 --- a/resources/js/pages/forms/submissions/Index.vue +++ b/resources/js/pages/forms/submissions/Index.vue @@ -1,11 +1,12 @@ diff --git a/src/Forms/Exporters/CsvExporter.php b/src/Forms/Exporters/CsvExporter.php index 9b8cc54b12e..50c16ffb580 100644 --- a/src/Forms/Exporters/CsvExporter.php +++ b/src/Forms/Exporters/CsvExporter.php @@ -31,31 +31,41 @@ private function insertHeaders() { $key = Arr::get($this->config, 'headers', config('statamic.forms.csv_headers', 'handle')); - $headers = $this->form->fields() - ->map(fn ($field) => $key === 'display' ? $field->display() : $field->handle()) - ->push($key === 'display' ? __('Date') : 'date') - ->values()->all(); + $headers = $this->columns()->map(function ($handle) use ($key) { + if ($key !== 'display') { + return $handle; + } + + return $handle === 'date' ? __('Date') : $this->form->fields()->get($handle)->display(); + })->all(); $this->writer->insertOne($headers); } private function insertData() { - $data = $this->submissions()->map(function ($submission) { - $submission = $submission->toArray(); + $columns = $this->columns(); + + $data = $this->submissions()->map(function ($submission) use ($columns) { + $values = $submission->toArray(); - $submission['date'] = (string) $submission['date']; + $values['date'] = (string) $values['date']; - unset($submission['id']); + return $columns->map(function ($column) use ($values) { + $value = $values[$column] ?? null; - return collect($submission)->map(function ($value) { - return (is_array($value)) ? implode(', ', $value) : $value; + return is_array($value) ? implode(', ', $value) : $value; })->all(); })->all(); $this->writer->insertAll($data); } + public function supportsColumnSelection(): bool + { + return true; + } + public function extension(): string { return 'csv'; diff --git a/src/Forms/Exporters/Exporter.php b/src/Forms/Exporters/Exporter.php index 1675bc4bd27..a3c9de9f90b 100644 --- a/src/Forms/Exporters/Exporter.php +++ b/src/Forms/Exporters/Exporter.php @@ -17,6 +17,7 @@ abstract class Exporter protected string $handle; protected Form $form; protected ?Collection $submissions = null; + protected ?array $columns = null; abstract public function export(): string; @@ -58,6 +59,29 @@ protected function submissions(): Collection return $this->submissions ?? $this->form->submissions(); } + public function setColumns(array $columns): static + { + $this->columns = $columns; + + return $this; + } + + protected function columns(): Collection + { + $available = $this->form->fields()->keys()->push('date'); + + if ($this->columns === null) { + return $available; + } + + return $available->intersect($this->columns)->values(); + } + + public function supportsColumnSelection(): bool + { + return false; + } + public function contentType(): string { return 'text/plain'; diff --git a/src/Forms/Exporters/JsonExporter.php b/src/Forms/Exporters/JsonExporter.php index c9fc34b8ea3..1729d562abf 100644 --- a/src/Forms/Exporters/JsonExporter.php +++ b/src/Forms/Exporters/JsonExporter.php @@ -2,17 +2,28 @@ namespace Statamic\Forms\Exporters; +use Statamic\Support\Arr; + class JsonExporter extends Exporter { protected static string $title = 'JSON'; public function export(): string { - $submissions = $this->submissions()->toArray(); + $columns = $this->columns()->push('id')->all(); + + $submissions = $this->submissions() + ->map(fn ($submission) => Arr::only($submission->toArray(), $columns)) + ->all(); return json_encode($submissions); } + public function supportsColumnSelection(): bool + { + return true; + } + public function contentType(): string { return 'application/json'; diff --git a/src/Http/Controllers/CP/Forms/FormExportController.php b/src/Http/Controllers/CP/Forms/FormExportController.php index 2297994d8a2..0460cb2e8e5 100644 --- a/src/Http/Controllers/CP/Forms/FormExportController.php +++ b/src/Http/Controllers/CP/Forms/FormExportController.php @@ -25,6 +25,10 @@ public function export(FilteredRequest $request, $form, $type) $exporter->setSubmissions($this->getScopedSubmissions($request, $form)); } + if ($request->filled('columns')) { + $exporter->setColumns(explode(',', $request->input('columns'))); + } + return $request->has('download') ? $exporter->download() : $exporter->response(); } diff --git a/src/Http/Controllers/CP/Forms/FormSubmissionsController.php b/src/Http/Controllers/CP/Forms/FormSubmissionsController.php index 6f6b9556c50..f173804e82a 100644 --- a/src/Http/Controllers/CP/Forms/FormSubmissionsController.php +++ b/src/Http/Controllers/CP/Forms/FormSubmissionsController.php @@ -59,7 +59,12 @@ public function index(FilteredRequest $request, $form) 'handle' => $exporter->handle(), 'title' => $exporter->title(), 'downloadUrl' => $exporter->downloadUrl(), + 'supportsColumnSelection' => $exporter->supportsColumnSelection(), ])->values(), + 'exportColumns' => $form->fields() + ->map(fn ($field) => ['handle' => $field->handle(), 'title' => __($field->display())]) + ->push(['handle' => 'date', 'title' => __('Date')]) + ->values(), 'redirectUrl' => cp_route('forms.index'), ]); } diff --git a/tests/Feature/Forms/FormExportTest.php b/tests/Feature/Forms/FormExportTest.php index 04dfe2f66c2..2f1a4227474 100644 --- a/tests/Feature/Forms/FormExportTest.php +++ b/tests/Feature/Forms/FormExportTest.php @@ -34,6 +34,26 @@ public function it_exports_with_the_view_form_submissions_permission() ->assertSuccessful(); } + #[Test] + public function it_exports_only_the_requested_columns() + { + $this->setTestRoles(['test' => ['access cp', 'view form submissions']]); + $user = tap(User::make()->assignRole('test'))->save(); + $form = tap(Form::make('test')->formFields([ + 'fields' => [ + ['handle' => 'name', 'field' => ['type' => 'short_answer']], + ['handle' => 'email', 'field' => ['type' => 'short_answer']], + ], + ]))->save(); + + $this + ->actingAs($user) + ->get(cp_route('forms.export', ['form' => $form->handle(), 'type' => 'csv', 'columns' => 'name,date'])) + ->assertSuccessful() + ->assertSee("name,date\n", false) + ->assertDontSee('email'); + } + #[Test] public function it_exports_with_the_per_form_view_submissions_permission() { diff --git a/tests/Forms/CsvExporterTest.php b/tests/Forms/CsvExporterTest.php index fb66c310994..a24af8dfa1a 100644 --- a/tests/Forms/CsvExporterTest.php +++ b/tests/Forms/CsvExporterTest.php @@ -29,4 +29,50 @@ public function it_neutralizes_formula_injection_in_submission_values() $this->assertStringContainsString('\'=1+1', $csv); $this->assertStringNotContainsString('"=1+1', $csv); } + + #[Test] + public function it_exports_all_columns_by_default() + { + $form = $this->makeFormWithSubmission(); + + $csv = (new CsvExporter)->setForm($form)->setConfig([])->export(); + + $this->assertStringStartsWith("name,email,date\n", $csv); + $this->assertStringContainsString('John,john@example.com,', $csv); + } + + #[Test] + public function it_exports_only_the_selected_columns_in_form_order() + { + $form = $this->makeFormWithSubmission(); + + $csv = (new CsvExporter)->setForm($form)->setConfig([])->setColumns(['date', 'name', 'unknown'])->export(); + + $this->assertStringStartsWith("name,date\n", $csv); + $this->assertStringNotContainsString('john@example.com', $csv); + } + + #[Test] + public function it_uses_display_names_for_selected_columns() + { + $form = $this->makeFormWithSubmission(); + + $csv = (new CsvExporter)->setForm($form)->setConfig(['headers' => 'display'])->setColumns(['email', 'date'])->export(); + + $this->assertStringStartsWith("\"Email Address\",Date\n", $csv); + } + + private function makeFormWithSubmission() + { + $form = tap(Form::make('test')->formFields([ + 'fields' => [ + ['handle' => 'name', 'field' => ['type' => 'short_answer']], + ['handle' => 'email', 'field' => ['type' => 'short_answer', 'display' => 'Email Address']], + ], + ]))->save(); + + FormSubmission::make()->form($form)->data(['name' => 'John', 'email' => 'john@example.com'])->save(); + + return $form; + } } diff --git a/tests/Forms/JsonExporterTest.php b/tests/Forms/JsonExporterTest.php new file mode 100644 index 00000000000..d7aef8bce99 --- /dev/null +++ b/tests/Forms/JsonExporterTest.php @@ -0,0 +1,51 @@ +makeFormWithSubmission(); + + $json = json_decode((new JsonExporter)->setForm($form)->setConfig([])->export(), true); + + $this->assertCount(1, $json); + $this->assertEquals(['name', 'email', 'id', 'date'], array_keys($json[0])); + } + + #[Test] + public function it_exports_only_the_selected_columns() + { + $form = $this->makeFormWithSubmission(); + + $json = json_decode((new JsonExporter)->setForm($form)->setConfig([])->setColumns(['email'])->export(), true); + + $this->assertEquals(['email', 'id'], array_keys($json[0])); + $this->assertEquals('john@example.com', $json[0]['email']); + } + + private function makeFormWithSubmission() + { + $form = tap(Form::make('test')->formFields([ + 'fields' => [ + ['handle' => 'name', 'field' => ['type' => 'short_answer']], + ['handle' => 'email', 'field' => ['type' => 'short_answer']], + ], + ]))->save(); + + FormSubmission::make()->form($form)->data(['name' => 'John', 'email' => 'john@example.com'])->save(); + + return $form; + } +}