Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lang/en/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
112 changes: 112 additions & 0 deletions resources/js/components/forms/ExportSubmissionsModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
<script setup>
import { ref, computed } from 'vue';
import { Modal, Button, RadioGroup, Radio, CheckboxGroup, Checkbox } from '@ui';

const emit = defineEmits(['close']);

const props = defineProps({
exporters: { type: Array, required: true },
columns: { type: Array, required: true },
listingParameters: { type: Object, default: () => ({}) },
});

const format = ref(props.exporters[0]?.handle ?? null);
const scope = ref('all');
const selectedColumns = ref(props.columns.map((column) => column.handle));

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

const selectedExporter = computed(() => props.exporters.find((exporter) => exporter.handle === format.value));

const canSelectColumns = computed(() => selectedExporter.value?.supportsColumnSelection ?? false);

const allColumnsSelected = computed(() => selectedColumns.value.length === props.columns.length);

const canExport = computed(() => selectedExporter.value && (!canSelectColumns.value || selectedColumns.value.length > 0));

function toggleAllColumns() {
selectedColumns.value = allColumnsSelected.value ? [] : props.columns.map((column) => column.handle);
}

function exportSubmissions() {
if (!canExport.value) return;

const query = new URLSearchParams();

if (scope.value === 'filtered') {
const params = props.listingParameters;
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);
}

if (canSelectColumns.value && !allColumnsSelected.value) {
query.set('columns', selectedColumns.value.join(','));
}

let url = selectedExporter.value.downloadUrl;

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

window.open(url, '_blank');
emit('close');
}
</script>

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

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

<div v-if="canSelectColumns">
<div class="flex items-center justify-between mb-1.5">
<label class="text-sm font-medium block">{{ __('Columns') }}</label>
<button
type="button"
class="cursor-pointer text-xs text-gray-500 hover:text-gray-800 dark:hover:text-gray-200"
@click="toggleAllColumns"
>
{{ allColumnsSelected ? __('Deselect All') : __('Select All') }}
</button>
</div>
<div class="max-h-48 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 p-3">
<CheckboxGroup v-model="selectedColumns">
<Checkbox v-for="column in columns" :key="column.handle" :value="column.handle" :label="column.title" />
</CheckboxGroup>
</div>
</div>
</div>

<template #footer>
<div class="flex justify-end p-2">
<Button variant="primary" :text="__('Export')" :disabled="!canExport" @click="exportSubmissions" />
</div>
</template>
</Modal>
</template>
69 changes: 11 additions & 58 deletions resources/js/pages/forms/submissions/Index.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
<script setup>
import { ref, computed } from 'vue';
import { ref } from 'vue';
import axios from 'axios';
import Head from '@/pages/layout/Head.vue';
import { Header, Dropdown, DropdownMenu, DropdownItem, Button, Modal, RadioGroup, Radio, CommandPaletteItem } from '@ui';
import { Header, Dropdown, DropdownMenu, DropdownItem, Button, CommandPaletteItem } from '@ui';
import FormStatusIndicator from '@/components/forms/FormStatusIndicator.vue';
import ResourceDeleter from '@/components/ResourceDeleter.vue';
import FormSubmissionListing from '@/components/forms/SubmissionListing.vue';
import ExportSubmissionsModal from '@/components/forms/ExportSubmissionsModal.vue';
import Layout from '@/pages/layout/Layout.vue';
import PanelLayout from '@/pages/layout/PanelLayout.vue';
import FormsLayout from '@/pages/forms/Layout.vue';
Expand All @@ -20,6 +21,7 @@ const props = defineProps([
'actionUrl',
'generateFakeSubmissionUrl',
'exporters',
'exportColumns',
'redirectUrl',
]);

Expand All @@ -28,8 +30,6 @@ const generatingFakeSubmission = ref(false);
const deletingFakeSubmissions = ref(false);
const submissionListing = ref();
const exportModalOpen = ref(false);
const exportFormat = ref(null);
const exportScope = ref('all');
const listingParameters = ref({});

async function generateFakeSubmission(mode) {
Expand Down Expand Up @@ -82,40 +82,10 @@ async function deleteFakeSubmissions() {
}
}

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 @@ -237,29 +207,12 @@ function exportSubmissions() {
: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>
<ExportSubmissionsModal
v-if="exportModalOpen"
:exporters
:columns="exportColumns"
:listing-parameters
@close="exportModalOpen = false"
/>
</div>
</template>
30 changes: 20 additions & 10 deletions src/Forms/Exporters/CsvExporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
24 changes: 24 additions & 0 deletions src/Forms/Exporters/Exporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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';
Expand Down
13 changes: 12 additions & 1 deletion src/Forms/Exporters/JsonExporter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 4 additions & 0 deletions src/Http/Controllers/CP/Forms/FormExportController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
5 changes: 5 additions & 0 deletions src/Http/Controllers/CP/Forms/FormSubmissionsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
]);
}
Expand Down
Loading
Loading