Skip to content
1 change: 1 addition & 0 deletions lang/en/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@
'form_configure_mailer_instructions' => 'Choose the mailer for sending this email. Leave blank to fall back to the default mailer.',
'form_configure_store_instructions' => 'Disable to stop storing submissions. Events and email notifications will still be sent.',
'form_configure_title_instructions' => 'Use a call to action, such as \'Contact Us\'.',
'form_export_filtered_description' => 'Exports submissions with current filters and visible columns.',
'form_create_description' => 'Get started by creating your first form.',
'getting_started_widget_collections' => 'Collections hold the different content types that make up your site, helping you stay organized.',
'getting_started_widget_docs' => 'Discover everything Statamic can do, and learn how to use its powerful features the right way.',
Expand Down
9 changes: 9 additions & 0 deletions resources/js/components/forms/SubmissionListing.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<template>
<Listing
ref="listing"
:url="requestUrl"
:columns="columns"
:action-url="actionUrl"
Expand Down Expand Up @@ -42,5 +43,13 @@ export default {
requestUrl: cp_url(`forms/${this.form}/submissions`),
};
},

computed: {
parameters() {
return this.$refs.listing?.parameters;
},
},

expose: ['parameters'],
};
</script>
1 change: 1 addition & 0 deletions resources/js/components/ui/Listing/Listing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,7 @@ provideListingContext({
defineExpose({
refresh,
setFilter,
parameters,
});

watch(parameters, (newParams, oldParams) => {
Expand Down
94 changes: 73 additions & 21 deletions resources/js/pages/forms/Show.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup>
import { ref } from 'vue';
import { ref, computed } from 'vue';
import Head from '@/pages/layout/Head.vue';
import { Header, Dropdown, DropdownMenu, DropdownItem, Button, CommandPaletteItem } from '@ui';
import { Header, Dropdown, DropdownMenu, DropdownItem, Button, Modal, RadioGroup, Radio, CommandPaletteItem } from '@ui';
import ResourceDeleter from '@/components/ResourceDeleter.vue';
import FormSubmissionListing from '@/components/forms/SubmissionListing.vue';

Expand All @@ -15,6 +15,46 @@ const props = defineProps([
]);

const deleter = ref(null);
const submissionListing = ref(null);
const exportModalOpen = ref(false);
const exportFormat = ref(null);
const exportScope = ref('all');
const listingParameters = ref({});

const hasFilteredScope = computed(() => {
const params = listingParameters.value;
const hasSortOverride = (params.sort && params.sort !== 'datestamp') || (params.order && params.order !== 'desc');
return !!(params.search || params.filters || hasSortOverride);
});

function openExportModal() {
listingParameters.value = submissionListing.value?.parameters ?? {};
exportFormat.value = props.exporters[0]?.handle ?? null;
exportScope.value = 'all';
exportModalOpen.value = true;
}

function exportSubmissions() {
const exporter = props.exporters.find((e) => e.handle === exportFormat.value);
if (!exporter) return;

let url = exporter.downloadUrl;

if (exportScope.value === 'filtered') {
const params = listingParameters.value;
const query = new URLSearchParams();
if (params.search) query.set('search', params.search);
if (params.sort) query.set('sort', params.sort);
if (params.order) query.set('order', params.order);
if (params.filters) query.set('filters', params.filters);

const separator = url.includes('?') ? '&' : '?';
url += separator + query.toString();
}

window.open(url, '_blank');
exportModalOpen.value = false;
}
</script>

<template>
Expand Down Expand Up @@ -70,39 +110,51 @@ const deleter = ref(null);
:redirect="redirectUrl"
/>

<Dropdown v-if="exporters.length">
<template #trigger>
<Button :text="__('Export Submissions')" />
</template>
<DropdownMenu>
<DropdownItem
v-for="exporter in exporters"
:key="exporter.downloadUrl"
:text="exporter.title"
:href="exporter.downloadUrl"
target="_blank"
/>
</DropdownMenu>
</Dropdown>
<Button v-if="exporters.length" :text="__('Export Submissions')" @click="openExportModal" />

<CommandPaletteItem
v-for="exporter in exporters"
:key="exporter.downloadUrl"
v-if="exporters.length"
category="Actions"
:text="[__('Export Submissions'), exporter.title]"
:text="__('Export Submissions')"
icon="save"
:url="exporter.downloadUrl"
:action="openExportModal"
prioritize
/>
</Header>

<FormSubmissionListing
ref="submissionListing"
:form="form.handle"
:action-url="actionUrl"
sort-column="datestamp"
sort-direction="desc"
:columns="columns"
:filters="filters"
:filters="filters"
/>

<Modal :open="exportModalOpen" @update:open="exportModalOpen = $event" :title="__('Export Submissions')">
<div class="space-y-4">
<div>
<label class="text-sm font-medium mb-1.5 block">{{ __('Format') }}</label>
<RadioGroup v-model="exportFormat" inline>
<Radio v-for="format in exporters" :key="format.handle" :value="format.handle" :label="format.title" />
</RadioGroup>
</div>

<div>
<label class="text-sm font-medium mb-1.5 block">{{ __('Submissions') }}</label>
<RadioGroup v-model="exportScope">
<Radio value="all" :label="__('All Submissions')" />
<Radio value="filtered" :label="__('Filtered Submissions')" :description="__('statamic::messages.form_export_filtered_description')" :disabled="!hasFilteredScope" />
</RadioGroup>
</div>
</div>

<template #footer>
<div class="flex justify-end p-2">
<Button variant="primary" :text="__('Export')" @click="exportSubmissions" />
</div>
</template>
</Modal>
</div>
</template>
2 changes: 1 addition & 1 deletion src/Forms/Exporters/CsvExporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ private function insertHeaders()

private function insertData()
{
$data = $this->form->submissions()->map(function ($submission) {
$data = $this->submissions()->map(function ($submission) {
$submission = $submission->toArray();

$submission['date'] = (string) $submission['date'];
Expand Down
19 changes: 19 additions & 0 deletions src/Forms/Exporters/Exporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Statamic\Forms\Exporters;

use Illuminate\Http\Response;
use Illuminate\Support\Collection;
use Statamic\Contracts\Forms\Form;
use Statamic\Facades\File;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
Expand All @@ -15,6 +16,7 @@ abstract class Exporter
protected array $config;
protected string $handle;
protected Form $form;
protected ?Collection $submissions = null;

abstract public function export(): string;

Expand All @@ -25,6 +27,11 @@ public function setHandle(string $handle)
return $this;
}

public function handle(): string
{
return $this->handle;
}

public function setConfig(array $config)
{
$this->config = $config;
Expand All @@ -39,6 +46,18 @@ public function setForm(Form $form)
return $this;
}

public function setSubmissions(Collection $submissions)
{
$this->submissions = $submissions;

return $this;
}

protected function submissions(): Collection
{
return $this->submissions ?? $this->form->submissions();
}

public function contentType(): string
{
return 'text/plain';
Expand Down
2 changes: 1 addition & 1 deletion src/Forms/Exporters/JsonExporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ class JsonExporter extends Exporter

public function export(): string
{
$submissions = $this->form->submissions()->toArray();
$submissions = $this->submissions()->toArray();

return json_encode($submissions);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace Statamic\Http\Controllers\CP\Forms\Concerns;

use Statamic\Contracts\Forms\Form;
use Statamic\Fields\Field;

trait QueriesFormSubmissionSearch
{
protected function applySubmissionSearch($query, Form $form, ?string $search)
{
if (! $search) {
return $query;
}

$query->where(function ($query) use ($form, $search) {
$query->where('date', 'like', '%'.$search.'%');

$form->blueprint()->fields()->all()
->filter(function (Field $field): bool {
return in_array($field->type(), ['text', 'textarea', 'integer']);
})
->each(function (Field $field) use ($query, $search): void {
$query->orWhere($field->handle(), 'like', '%'.$search.'%');
});
});

return $query;
}
}
36 changes: 34 additions & 2 deletions src/Http/Controllers/CP/Forms/FormExportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,49 @@

use Statamic\Exceptions\NotFoundHttpException;
use Statamic\Http\Controllers\CP\CpController;
use Statamic\Http\Controllers\CP\Forms\Concerns\QueriesFormSubmissionSearch;
use Statamic\Http\Requests\FilteredRequest;
use Statamic\Query\OrderBy;
use Statamic\Query\Scopes\Filters\Concerns\QueriesFilters;

class FormExportController extends CpController
{
public function export($form, $type)
use QueriesFilters, QueriesFormSubmissionSearch;

public function export(FilteredRequest $request, $form, $type)
{
$this->authorize('view', $form);

if (! $exporter = $form->exporter($type)) {
throw new NotFoundHttpException;
}

return $this->request->has('download') ? $exporter->download() : $exporter->response();
if ($this->shouldApplyFilteredScope($request)) {
$exporter->setSubmissions($this->getScopedSubmissions($request, $form));
}

return $request->has('download') ? $exporter->download() : $exporter->response();
}

protected function shouldApplyFilteredScope(FilteredRequest $request)
{
return $request->has('filters') || $request->has('search') || $request->has('sort') || $request->has('order');
}

protected function getScopedSubmissions(FilteredRequest $request, $form)
{
$query = $form->querySubmissions();

$this->queryFilters($query, $request->filters, [
'form' => $form->handle(),
]);

$this->applySubmissionSearch($query, $form, $request->input('search'));

if ($sort = OrderBy::column($request->input('sort'))) {
$query->orderBy($sort, $request->input('order', $sort === 'date' ? 'desc' : 'asc'));
}

return $query->get();
}
}
18 changes: 3 additions & 15 deletions src/Http/Controllers/CP/Forms/FormSubmissionsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@
namespace Statamic\Http\Controllers\CP\Forms;

use Inertia\Inertia;
use Statamic\Fields\Field;
use Statamic\Http\Controllers\CP\CpController;
use Statamic\Http\Controllers\CP\Forms\Concerns\QueriesFormSubmissionSearch;
use Statamic\Http\Requests\FilteredRequest;
use Statamic\Http\Resources\CP\Submissions\Submissions;
use Statamic\Query\OrderBy;
use Statamic\Query\Scopes\Filters\Concerns\QueriesFilters;

class FormSubmissionsController extends CpController
{
use QueriesFilters;
use QueriesFilters, QueriesFormSubmissionSearch;

public function index(FilteredRequest $request, $form)
{
Expand Down Expand Up @@ -49,19 +49,7 @@ protected function indexQuery($form)
{
$query = $form->querySubmissions();

if ($search = request('search')) {
$query->where(function ($query) use ($form, $search) {
$query->where('date', 'like', '%'.$search.'%');

$form->blueprint()->fields()->all()
->filter(function (Field $field): bool {
return in_array($field->type(), ['text', 'textarea', 'integer']);
})
->each(function (Field $field) use ($query, $search): void {
$query->orWhere($field->handle(), 'like', '%'.$search.'%');
});
});
}
$this->applySubmissionSearch($query, $form, request('search'));

return $query;
}
Expand Down
1 change: 1 addition & 0 deletions src/Http/Controllers/CP/Forms/FormsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ public function show($form)
]),
'actionUrl' => cp_route('forms.submissions.actions.run', $form->handle()),
'exporters' => $form->exporters()->map(fn ($exporter) => [
'handle' => $exporter->handle(),
'title' => $exporter->title(),
'downloadUrl' => $exporter->downloadUrl(),
])->values(),
Expand Down
Loading