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> <template>
<div class="flex items-center justify-center space-x-1"> <UButtonGroup size="xs" orientation="horizontal">
<button <UButton
v-track.delete_record_click 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" @click="showEditSubmissionModal = true"
> />
<svg <UButton
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
v-track.delete_record_click 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" @click="onDeleteClick"
> />
<svg </UButtonGroup>
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>
<EditSubmissionModal <EditSubmissionModal
:show="showEditSubmissionModal" :show="showEditSubmissionModal"
:form="form" :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 Form Submissions
</h3> </h3>
<!-- Table columns modal --> <!-- Settings Modal -->
<modal <form-columns-settings-modal
:show="showColumnsModal" :show="showColumnsModal"
@close="showColumnsModal=false" :form="form"
> :columns="properties"
<template #icon> v-model:display-columns="displayColumns"
<svg v-model:wrap-columns="wrapColumns"
class="w-8 h-8" @close="showColumnsModal = false"
viewBox="0 0 24 24" @update:columns="onColumnUpdated"
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>
<Loader <Loader
v-if="!form" v-if="!form"
@ -99,7 +26,7 @@
<div v-else> <div v-else>
<div <div
v-if="form && tableData.length > 0" v-if="form && tableData.length > 0"
class="flex flex-wrap items-end" class="flex flex-wrap items-center mb-4"
> >
<div class="flex-grow"> <div class="flex-grow">
<VForm size="sm"> <VForm size="sm">
@ -111,26 +38,22 @@
/> />
</VForm> </VForm>
</div> </div>
<div class="font-semibold flex gap-4"> <div class="font-semibold flex gap-2">
<p class="float-right text-xs uppercase mb-2"> <UButton
<a size="sm"
href="javascript:void(0);" color="white"
class="text-gray-500" icon="heroicons:adjustments-horizontal"
@click="showColumnsModal=true" label="Edit columns"
>Display columns</a> @click="showColumnsModal=true"
</p> />
<p <UButton
v-if="!exportLoading" size="sm"
class="text-right cursor-pointer text-xs uppercase" color="white"
> icon="heroicons:arrow-down-tray"
<a label="Export"
href="#" :loading="exportLoading"
@click.prevent="downloadAsCsv" @click="downloadAsCsv"
>Export as CSV</a> />
</p>
<p v-else>
<loader class="w-3 h-3 text-blue-500" />
</p>
</div> </div>
</div> </div>
</div> </div>
@ -147,6 +70,7 @@
ref="table" ref="table"
class="max-h-full" class="max-h-full"
:columns="properties" :columns="properties"
:wrap-columns="wrapColumns"
:data="filteredData" :data="filteredData"
:loading="isLoading" :loading="isLoading"
:scroll-parent="parentPage" :scroll-parent="parentPage"
@ -164,20 +88,24 @@
import Fuse from 'fuse.js' import Fuse from 'fuse.js'
import clonedeep from 'clone-deep' import clonedeep from 'clone-deep'
import OpenTable from '../../tables/OpenTable.vue' import OpenTable from '../../tables/OpenTable.vue'
import FormColumnsSettingsModal from './FormColumnsSettingsModal.vue'
export default { export default {
name: 'FormSubmissions', name: 'FormSubmissions',
components: {OpenTable}, components: { OpenTable, FormColumnsSettingsModal },
props: {}, props: {},
setup() { setup() {
const workingFormStore = useWorkingFormStore() const workingFormStore = useWorkingFormStore()
const recordStore = useRecordsStore() const recordStore = useRecordsStore()
const form = storeToRefs(workingFormStore).content
const tableData = storeToRefs(recordStore).getAll
return { return {
workingFormStore, workingFormStore,
recordStore, recordStore,
form: storeToRefs(workingFormStore).content, form,
tableData: storeToRefs(recordStore).getAll, tableData,
runtimeConfig: useRuntimeConfig(), runtimeConfig: useRuntimeConfig(),
slug: useRoute().params.slug slug: useRoute().params.slug
} }
@ -188,14 +116,15 @@ export default {
fullyLoaded: false, fullyLoaded: false,
showColumnsModal: false, showColumnsModal: false,
properties: [], properties: [],
removed_properties: [],
displayColumns: {},
exportLoading: false, exportLoading: false,
searchForm: useForm({ searchForm: useForm({
search: '' search: ''
}), }),
displayColumns: {},
wrapColumns: {},
} }
}, },
computed: { computed: {
parentPage() { parentPage() {
if (import.meta.server) { if (import.meta.server) {
@ -203,11 +132,6 @@ export default {
} }
return window 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() { exportUrl() {
if (!this.form) { if (!this.form) {
return '' return ''
@ -234,8 +158,9 @@ export default {
return fuse.search(this.searchForm.search).map((res) => { return fuse.search(this.searchForm.search).map((res) => {
return res.item return res.item
}) })
} },
}, },
watch: { watch: {
'form.id'() { 'form.id'() {
this.onFormChange() this.onFormChange()
@ -244,46 +169,19 @@ export default {
this.dataChanged() this.dataChanged()
} }
}, },
mounted() { mounted() {
this.onFormChange() this.onFormChange()
}, },
methods: { methods: {
onFormChange() { onFormChange() {
if (this.form === null || this.form.slug !== this.slug) { if (this.form === null || this.form.slug !== this.slug) {
return return
} }
this.fullyLoaded = false this.fullyLoaded = false
this.initFormStructure()
this.getSubmissionsData() 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() { getSubmissionsData() {
if (this.fullyLoaded) { if (this.fullyLoaded) {
return return
@ -328,13 +226,6 @@ export default {
onColumnUpdated(columns) { onColumnUpdated(columns) {
this.properties = 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) { onUpdateRecord(submission) {
this.recordStore.save(submission) this.recordStore.save(submission)
this.dataChanged() this.dataChanged()
@ -355,16 +246,16 @@ export default {
columns: this.displayColumns columns: this.displayColumns
} }
}).then(blob => { }).then(blob => {
const filename = `${this.form.slug}-${Date.now()}-submissions.csv` const filename = `${this.form.slug}-${Date.now()}-submissions.csv`
const a = document.createElement("a") const a = document.createElement("a")
document.body.appendChild(a) document.body.appendChild(a)
a.style = "display: none" a.style = "display: none"
const url = window.URL.createObjectURL(blob) const url = window.URL.createObjectURL(blob)
a.href = url a.href = url
a.download = filename a.download = filename
a.click() a.click()
window.URL.revokeObjectURL(url) window.URL.revokeObjectURL(url)
}).catch((error) => { }).catch((error) => {
console.error(error) console.error(error)
}).finally(() => { }).finally(() => {
this.exportLoading = false this.exportLoading = false

View File

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