Enhance form submissions column management (#691)

* Enhance form submissions column management

* Modernize UI components with Nuxt UI and improved styling

- Refactor RecordOperations component to use UButtonGroup and UButton
- Update FormSubmissions component with Nuxt UI buttons and modal
- Improve table cell styling in OpenTable component
- Simplify column management and export functionality

---------

Co-authored-by: Julien Nahum <julien@nahum.net>
This commit is contained in:
Chirag Chhatrala 2025-02-12 18:18:06 +05:30 committed by GitHub
parent aae28d09cc
commit 29ef44d50e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 320 additions and 205 deletions

View File

@ -1,46 +1,20 @@
<template>
<div class="flex items-center justify-center space-x-1">
<button
<UButtonGroup size="xs" orientation="horizontal">
<UButton
v-track.delete_record_click
class="border rounded py-1 px-2 text-gray-500 dark:text-gray-400 hover:text-blue-700"
size="sm"
color="white"
icon="heroicons:pencil-square"
@click="showEditSubmissionModal = true"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L6.832 19.82a4.5 4.5 0 0 1-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 0 1 1.13-1.897L16.863 4.487Zm0 0L19.5 7.125"
/>
</svg>
</button>
<button
/>
<UButton
v-track.delete_record_click
class="border rounded py-1 px-2 text-gray-500 dark:text-gray-400 hover:text-red-700"
size="sm"
color="white"
icon="heroicons:trash"
@click="onDeleteClick"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"
/>
</svg>
</button>
</div>
/>
</UButtonGroup>
<EditSubmissionModal
:show="showEditSubmissionModal"
:form="form"

View File

@ -0,0 +1,243 @@
<template>
<modal
compact-header
:show="show"
@close="$emit('close')"
>
<template #icon>
<Icon name="heroicons:adjustments-horizontal" class="w-8 h-8" />
</template>
<template #title>
Manage Columns
</template>
<div class="px-4">
<template
v-for="(section, sectionIndex) in sections"
:key="sectionIndex"
>
<template v-if="section.fields.length > 0">
<h4
class="font-semibold mb-2"
:class="{ 'mt-4': sectionIndex > 0 }"
>
{{ section.title }}
</h4>
<div class="border border-gray-300">
<div class="grid grid-cols-[1fr,auto,auto] gap-4 px-4 py-2 bg-gray-50 border-b border-gray-300">
<div class="text-sm">
Name
</div>
<div class="text-sm text-center w-20">
Display
</div>
<div class="text-sm text-center w-20">
Wrap Text
</div>
</div>
<div
v-for="(field, index) in section.fields"
:key="field.id"
class="grid grid-cols-[1fr,auto,auto] gap-4 px-4 py-2 items-center"
:class="{ 'border-t border-gray-300': index !== 0 }"
>
<p class="truncate text-sm">
{{ field.name }}
</p>
<div class="flex justify-center w-20">
<ToggleSwitchInput
v-model="displayColumns[field.id]"
wrapper-class="mb-0"
label=""
:name="`display-${field.id}`"
@update:model-value="onChangeDisplayColumns"
/>
</div>
<div class="flex justify-center w-20">
<ToggleSwitchInput
v-model="wrapColumns[field.id]"
wrapper-class="mb-0"
label=""
:name="`wrap-${field.id}`"
/>
</div>
</div>
</div>
</template>
</template>
</div>
</modal>
</template>
<script setup>
import { useStorage } from '@vueuse/core'
import clonedeep from 'clone-deep'
const props = defineProps({
show: {
type: Boolean,
required: true
},
form: {
type: Object,
required: true
},
columns: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['close', 'update:columns', 'update:displayColumns', 'update:wrapColumns'])
const candidatesProperties = computed(() => {
if (!props.form?.properties) return []
const properties = clonedeep(props.form.properties).filter((field) => {
return !['nf-text', 'nf-code', 'nf-page-break', 'nf-divider', 'nf-image'].includes(field.type)
})
// Add created_at column if not present
if (!properties.find(property => property.id === 'created_at')) {
properties.push({
name: 'Created at',
id: 'created_at',
type: 'date',
width: 140
})
}
return properties
})
const sections = computed(() => [
{
title: 'Form Fields',
fields: candidatesProperties.value
},
{
title: 'Removed Fields',
fields: props.form?.removed_properties || []
}
])
// Column preferences storage
const columnPreferences = useStorage(
computed(() => props.form ? `column-preferences-formid-${props.form.id}` : null),
{
display: {},
wrap: {},
widths: {}
}
)
const displayColumns = computed({
get: () => columnPreferences.value.display,
set: (val) => {
columnPreferences.value.display = val
emit('update:displayColumns', val)
}
})
const wrapColumns = computed({
get: () => columnPreferences.value.wrap,
set: (val) => {
columnPreferences.value.wrap = val
emit('update:wrapColumns', val)
}
})
// Helper function to preserve column widths
function preserveColumnWidths(newColumns, existingColumns = []) {
if (!columnPreferences.value) {
columnPreferences.value = { display: {}, wrap: {}, widths: {} }
}
if (!columnPreferences.value.widths) {
columnPreferences.value.widths = {}
}
return newColumns.map(col => {
// First try to find width in storage
const storedWidth = columnPreferences.value?.widths?.[col.id]
// Then try current table columns
const currentCol = props.columns?.find(c => c.id === col.id)
// Then fallback to form properties
const existing = existingColumns?.find(e => e.id === col.id)
const width = storedWidth || currentCol?.cell_width || currentCol?.width || existing?.cell_width || existing?.width || col.width || 150
return {
...col,
width,
cell_width: width
}
})
}
// Watch for column width changes
watch(() => props.columns, (newColumns) => {
if (!newColumns?.length) return
if (!columnPreferences.value) {
columnPreferences.value = { display: {}, wrap: {}, widths: {} }
}
if (!columnPreferences.value.widths) {
columnPreferences.value.widths = {}
}
const widths = {}
newColumns.forEach(col => {
if (col.cell_width) {
widths[col.id] = col.cell_width
}
})
columnPreferences.value.widths = widths
}, { deep: true })
// Initialize display and wrap columns when form changes
watch(() => props.form, (newForm) => {
if (!newForm) return
const properties = newForm.properties || []
const storedPrefs = columnPreferences.value
// Initialize display columns if not set
if (!Object.keys(storedPrefs.display).length) {
properties.forEach((field) => {
storedPrefs.display[field.id] = true
})
}
// Initialize wrap columns if not set
if (!Object.keys(storedPrefs.wrap).length) {
properties.forEach((field) => {
storedPrefs.wrap[field.id] = false
})
}
// Emit initial values
emit('update:displayColumns', storedPrefs.display)
emit('update:wrapColumns', storedPrefs.wrap)
// Emit initial columns (all visible by default)
const initialColumns = clonedeep(candidatesProperties.value)
.concat(props.form?.removed_properties || [])
.filter((field) => storedPrefs.display[field.id] !== false) // Show all columns by default unless explicitly hidden
// Preserve any existing column widths
const columnsWithWidths = preserveColumnWidths(initialColumns, props.form.properties)
emit('update:columns', columnsWithWidths)
}, { immediate: true })
function onChangeDisplayColumns() {
if (!import.meta.client) return
const properties = clonedeep(candidatesProperties.value)
.concat(props.form?.removed_properties || [])
.filter((field) => displayColumns.value[field.id] === true)
// Preserve existing column widths when toggling visibility
const columnsWithWidths = preserveColumnWidths(properties, props.form.properties)
emit('update:columns', columnsWithWidths)
}
</script>

View File

@ -8,89 +8,16 @@
Form Submissions
</h3>
<!-- Table columns modal -->
<modal
<!-- Settings Modal -->
<form-columns-settings-modal
:show="showColumnsModal"
@close="showColumnsModal=false"
>
<template #icon>
<svg
class="w-8 h-8"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M16 5H8C4.13401 5 1 8.13401 1 12C1 15.866 4.13401 19 8 19H16C19.866 19 23 15.866 23 12C23 8.13401 19.866 5 16 5Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M8 15C9.65685 15 11 13.6569 11 12C11 10.3431 9.65685 9 8 9C6.34315 9 5 10.3431 5 12C5 13.6569 6.34315 15 8 15Z"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>
<template #title>
Display columns
</template>
<div class="px-4">
<template v-if="form.properties.length > 0">
<h4 class="font-bold mb-2">
Form Fields
</h4>
<div class="border border-gray-300 rounded-md">
<div
v-for="(field,index) in candidatesProperties"
:key="field.id"
class="p-2 border-gray-300 flex items-center"
:class="{'border-t':index!=0}"
>
<p class="flex-grow truncate">
{{ field.name }}
</p>
<ToggleSwitchInput
v-model="displayColumns[field.id]"
wrapper-class="mb-0"
label=""
name="field.id"
@update:model-value="onChangeDisplayColumns"
/>
</div>
</div>
</template>
<template v-if="removed_properties.length > 0">
<h4 class="font-bold mb-2 mt-4">
Removed Fields
</h4>
<div class="border border-gray-300 rounded-md">
<div
v-for="(field,index) in removed_properties"
:key="field.id"
class="p-2 border-gray-300 flex items-center"
:class="{'border-t':index!=0}"
>
<p class="flex-grow truncate">
{{ field.name }}
</p>
<ToggleSwitchInput
v-model="displayColumns[field.id]"
wrapper-class="mb-0"
label=""
name="field.id"
@update:model-value="onChangeDisplayColumns"
/>
</div>
</div>
</template>
</div>
</modal>
:form="form"
:columns="properties"
v-model:display-columns="displayColumns"
v-model:wrap-columns="wrapColumns"
@close="showColumnsModal = false"
@update:columns="onColumnUpdated"
/>
<Loader
v-if="!form"
@ -99,7 +26,7 @@
<div v-else>
<div
v-if="form && tableData.length > 0"
class="flex flex-wrap items-end"
class="flex flex-wrap items-center mb-4"
>
<div class="flex-grow">
<VForm size="sm">
@ -111,26 +38,22 @@
/>
</VForm>
</div>
<div class="font-semibold flex gap-4">
<p class="float-right text-xs uppercase mb-2">
<a
href="javascript:void(0);"
class="text-gray-500"
@click="showColumnsModal=true"
>Display columns</a>
</p>
<p
v-if="!exportLoading"
class="text-right cursor-pointer text-xs uppercase"
>
<a
href="#"
@click.prevent="downloadAsCsv"
>Export as CSV</a>
</p>
<p v-else>
<loader class="w-3 h-3 text-blue-500" />
</p>
<div class="font-semibold flex gap-2">
<UButton
size="sm"
color="white"
icon="heroicons:adjustments-horizontal"
label="Edit columns"
@click="showColumnsModal=true"
/>
<UButton
size="sm"
color="white"
icon="heroicons:arrow-down-tray"
label="Export"
:loading="exportLoading"
@click="downloadAsCsv"
/>
</div>
</div>
</div>
@ -147,6 +70,7 @@
ref="table"
class="max-h-full"
:columns="properties"
:wrap-columns="wrapColumns"
:data="filteredData"
:loading="isLoading"
:scroll-parent="parentPage"
@ -164,20 +88,24 @@
import Fuse from 'fuse.js'
import clonedeep from 'clone-deep'
import OpenTable from '../../tables/OpenTable.vue'
import FormColumnsSettingsModal from './FormColumnsSettingsModal.vue'
export default {
name: 'FormSubmissions',
components: {OpenTable},
components: { OpenTable, FormColumnsSettingsModal },
props: {},
setup() {
const workingFormStore = useWorkingFormStore()
const recordStore = useRecordsStore()
const form = storeToRefs(workingFormStore).content
const tableData = storeToRefs(recordStore).getAll
return {
workingFormStore,
recordStore,
form: storeToRefs(workingFormStore).content,
tableData: storeToRefs(recordStore).getAll,
form,
tableData,
runtimeConfig: useRuntimeConfig(),
slug: useRoute().params.slug
}
@ -188,14 +116,15 @@ export default {
fullyLoaded: false,
showColumnsModal: false,
properties: [],
removed_properties: [],
displayColumns: {},
exportLoading: false,
searchForm: useForm({
search: ''
}),
displayColumns: {},
wrapColumns: {},
}
},
computed: {
parentPage() {
if (import.meta.server) {
@ -203,11 +132,6 @@ export default {
}
return window
},
candidatesProperties() {
return clonedeep(this.form.properties).filter((field) => {
return !['nf-text', 'nf-code', 'nf-page-break', 'nf-divider', 'nf-image'].includes(field.type)
})
},
exportUrl() {
if (!this.form) {
return ''
@ -234,8 +158,9 @@ export default {
return fuse.search(this.searchForm.search).map((res) => {
return res.item
})
}
},
},
watch: {
'form.id'() {
this.onFormChange()
@ -244,46 +169,19 @@ export default {
this.dataChanged()
}
},
mounted() {
this.onFormChange()
},
methods: {
onFormChange() {
if (this.form === null || this.form.slug !== this.slug) {
return
}
this.fullyLoaded = false
this.initFormStructure()
this.getSubmissionsData()
},
initFormStructure() {
// check if form properties already has a created_at column
if (!this.properties.find((property) => {
if (property.id === 'created_at') {
return true
}
})) {
// Add a "created at" column
this.candidatesProperties.push({
name: 'Created at',
id: 'created_at',
type: 'date',
width: 140
})
}
this.properties = this.candidatesProperties
this.removed_properties = (this.form.removed_properties) ? clonedeep(this.form.removed_properties) : []
// Get display columns from local storage
const tmpColumns = window.localStorage.getItem('display-columns-formid-' + this.form.id)
if (tmpColumns !== null && tmpColumns) {
this.displayColumns = JSON.parse(tmpColumns)
this.onChangeDisplayColumns()
} else {
this.properties.forEach((field) => {
this.displayColumns[field.id] = true
})
}
},
getSubmissionsData() {
if (this.fullyLoaded) {
return
@ -328,13 +226,6 @@ export default {
onColumnUpdated(columns) {
this.properties = columns
},
onChangeDisplayColumns() {
if (!import.meta.client) return
window.localStorage.setItem('display-columns-formid-' + this.form.id, JSON.stringify(this.displayColumns))
this.properties = clonedeep(this.candidatesProperties).concat(this.removed_properties).filter((field) => {
return this.displayColumns[field.id] === true
})
},
onUpdateRecord(submission) {
this.recordStore.save(submission)
this.dataChanged()
@ -355,16 +246,16 @@ export default {
columns: this.displayColumns
}
}).then(blob => {
const filename = `${this.form.slug}-${Date.now()}-submissions.csv`
const a = document.createElement("a")
document.body.appendChild(a)
a.style = "display: none"
const url = window.URL.createObjectURL(blob)
a.href = url
a.download = filename
a.click()
window.URL.revokeObjectURL(url)
}).catch((error) => {
const filename = `${this.form.slug}-${Date.now()}-submissions.csv`
const a = document.createElement("a")
document.body.appendChild(a)
a.style = "display: none"
const url = window.URL.createObjectURL(blob)
a.href = url
a.download = filename
a.click()
window.URL.revokeObjectURL(url)
}).catch((error) => {
console.error(error)
}).finally(() => {
this.exportLoading = false

View File

@ -74,6 +74,7 @@
{
'border-b': index !== data.length - 1,
'border-r': colIndex !== columns.length - 1 || hasActions,
'whitespace-normal break-words': wrapColumns[col.id] === true,
},
colClasses(col),
]"
@ -90,13 +91,15 @@
class="n-table-cell border-gray-100 dark:border-gray-900 text-sm p-2 border-b"
style="width: 100px"
>
<record-operations
:form="form"
:structure="columns"
:submission="row"
@deleted="(submission) => $emit('deleted', submission)"
@updated="(submission) => $emit('updated', submission)"
/>
<div class="flex justify-center">
<record-operations
:form="form"
:structure="columns"
:submission="row"
@deleted="(submission) => $emit('deleted', submission)"
@updated="(submission) => $emit('updated', submission)"
/>
</div>
</td>
</tr>
<tr
@ -158,6 +161,10 @@ export default {
type: Array,
default: () => [],
},
wrapColumns: {
type: Object,
default: () => {},
},
data: {
type: Array,
default: () => [],