WIP - added spredsheet view using ag-grid

This commit is contained in:
Francisco Gaona
2026-02-06 22:01:59 +01:00
parent eb1619c56c
commit 5f14a4050a
4 changed files with 204 additions and 3 deletions

View File

@@ -1,5 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import type { CellValueChangedEvent, ColDef } from 'ag-grid-community'
import { AgGridVue } from 'ag-grid-vue3'
import 'ag-grid-community/styles/ag-grid.css'
import 'ag-grid-community/styles/ag-theme-alpine.css'
import { import {
Table, Table,
TableBody, TableBody,
@@ -14,7 +18,7 @@ import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import FieldRenderer from '@/components/fields/FieldRenderer.vue' import FieldRenderer from '@/components/fields/FieldRenderer.vue'
import { ListViewConfig, ViewMode, FieldType } from '@/types/field-types' import { ListViewConfig, ViewMode, FieldType, FieldConfig } from '@/types/field-types'
import { ChevronDown, ChevronUp, Search, Plus, Download, Trash2, Edit } from 'lucide-vue-next' import { ChevronDown, ChevronUp, Search, Plus, Download, Trash2, Edit } from 'lucide-vue-next'
interface Props { interface Props {
@@ -47,6 +51,8 @@ const emit = defineEmits<{
'refresh': [] 'refresh': []
'page-change': [page: number, pageSize: number] 'page-change': [page: number, pageSize: number]
'load-more': [page: number, pageSize: number] 'load-more': [page: number, pageSize: number]
'view-change': [mode: 'list' | 'spreadsheet']
'cell-edit': [payload: { row: any; field: FieldConfig; newValue: any; oldValue: any }]
}>() }>()
// State // State
@@ -57,6 +63,7 @@ const sortField = ref<string>('')
const sortDirection = ref<'asc' | 'desc'>('asc') const sortDirection = ref<'asc' | 'desc'>('asc')
const currentPage = ref(1) const currentPage = ref(1)
const bulkAction = ref('delete') const bulkAction = ref('delete')
const viewMode = ref<'list' | 'spreadsheet'>('list')
// Computed // Computed
const visibleFields = computed(() => const visibleFields = computed(() =>
@@ -87,7 +94,7 @@ const paginatedData = computed(() => {
}) })
const pageStart = computed(() => (props.data.length === 0 ? 0 : startIndex.value + 1)) const pageStart = computed(() => (props.data.length === 0 ? 0 : startIndex.value + 1))
const pageEnd = computed(() => Math.min(startIndex.value + paginatedData.value.length, totalRecords.value)) const pageEnd = computed(() => Math.min(startIndex.value + paginatedData.value.length, totalRecords.value))
const showPagination = computed(() => totalRecords.value > pageSize.value) const showPagination = computed(() => viewMode.value === 'list' && totalRecords.value > pageSize.value)
const canGoPrev = computed(() => currentPage.value > 1) const canGoPrev = computed(() => currentPage.value > 1)
const canGoNext = computed(() => currentPage.value < availablePages.value) const canGoNext = computed(() => currentPage.value < availablePages.value)
const showLoadMore = computed(() => ( const showLoadMore = computed(() => (
@@ -159,6 +166,108 @@ const handleBulkAction = () => {
emit('action', bulkAction.value, getSelectedRows()) emit('action', bulkAction.value, getSelectedRows())
} }
const isEditableField = (field: FieldConfig) =>
field.showOnEdit !== false &&
!field.isReadOnly &&
![FieldType.BELONGS_TO, FieldType.HAS_MANY, FieldType.MANY_TO_MANY].includes(field.type)
const getRelationPropertyName = (apiName: string) => apiName.replace(/Id$/, '').toLowerCase()
const formatFieldValue = (field: FieldConfig, value: any, record?: any) => {
if (value === null || value === undefined) return ''
switch (field.type) {
case FieldType.BELONGS_TO: {
const relationPropertyName = getRelationPropertyName(field.apiName)
const relatedObject = record?.[relationPropertyName]
if (relatedObject && typeof relatedObject === 'object') {
const displayField = field.relationDisplayField || 'name'
return relatedObject[displayField] || relatedObject.id || value
}
return value
}
case FieldType.DATE: {
const date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime())
? String(value)
: date.toLocaleDateString(undefined, { year: 'numeric', month: '2-digit', day: '2-digit' })
}
case FieldType.DATETIME: {
const date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime())
? String(value)
: date.toLocaleString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
})
}
case FieldType.BOOLEAN:
return value ? 'Yes' : 'No'
case FieldType.CURRENCY:
return `${field.prefix || '$'}${Number(value).toFixed(2)}${field.suffix || ''}`
case FieldType.SELECT: {
const option = field.options?.find(opt => opt.value === value)
return option?.label || value
}
case FieldType.MULTI_SELECT:
return Array.isArray(value)
? value
.map(v => field.options?.find(opt => opt.value === v)?.label || v)
.join(', ')
: ''
default:
return String(value)
}
}
const columnDefs = computed<ColDef[]>(() =>
visibleFields.value.map(field => ({
field: field.apiName,
headerName: field.label,
sortable: field.sortable !== false,
editable: isEditableField(field),
valueFormatter: params => formatFieldValue(field, params.value, params.data),
cellEditor:
field.type === FieldType.SELECT
? 'agSelectCellEditor'
: field.type === FieldType.MULTI_SELECT
? 'agTextCellEditor'
: field.type === FieldType.NUMBER || field.type === FieldType.CURRENCY
? 'agTextCellEditor'
: undefined,
cellEditorParams:
field.type === FieldType.SELECT
? { values: (field.options || []).map(opt => opt.value) }
: undefined,
valueParser:
field.type === FieldType.NUMBER || field.type === FieldType.CURRENCY
? params => (params.newValue === '' ? null : Number(params.newValue))
: undefined,
flex: 1,
minWidth: 160,
}))
)
const defaultColDef: ColDef = {
resizable: true,
sortable: true,
}
const handleCellValueChanged = (event: CellValueChangedEvent) => {
const field = visibleFields.value.find(item => item.apiName === event.colDef.field)
if (!field || event.newValue === event.oldValue) return
emit('cell-edit', {
row: event.data,
field,
newValue: event.newValue,
oldValue: event.oldValue,
})
}
const goToPage = (page: number) => { const goToPage = (page: number) => {
const nextPage = Math.min(Math.max(page, 1), availablePages.value) const nextPage = Math.min(Math.max(page, 1), availablePages.value)
if (nextPage !== currentPage.value) { if (nextPage !== currentPage.value) {
@@ -193,6 +302,13 @@ watch(
}, },
{ deep: true } { deep: true }
) )
watch(
() => viewMode.value,
(mode) => {
emit('view-change', mode)
}
)
</script> </script>
<template> <template>
@@ -216,6 +332,15 @@ watch(
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Select v-model="viewMode">
<SelectTrigger class="h-8 w-[180px]">
<SelectValue placeholder="Select view" />
</SelectTrigger>
<SelectContent>
<SelectItem value="list">List view</SelectItem>
<SelectItem value="spreadsheet">Spreadsheet view</SelectItem>
</SelectContent>
</Select>
<!-- Bulk Actions --> <!-- Bulk Actions -->
<template v-if="selectedRowIds.length > 0"> <template v-if="selectedRowIds.length > 0">
<Badge variant="secondary" class="px-3 py-1"> <Badge variant="secondary" class="px-3 py-1">
@@ -264,7 +389,7 @@ watch(
<!-- Table --> <!-- Table -->
<div class="border rounded-lg"> <div class="border rounded-lg">
<Table> <Table v-if="viewMode === 'list'">
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead v-if="selectable" class="w-12"> <TableHead v-if="selectable" class="w-12">
@@ -338,6 +463,18 @@ watch(
</TableRow> </TableRow>
</TableBody> </TableBody>
</Table> </Table>
<div v-else class="ag-theme-alpine">
<AgGridVue
:row-data="data"
:column-defs="columnDefs"
:default-col-def="defaultColDef"
dom-layout="autoHeight"
row-selection="multiple"
suppress-row-click-selection
single-click-edit
@cell-value-changed="handleCellValueChanged"
/>
</div>
</div> </div>
<div v-if="showPagination" class="flex flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground"> <div v-if="showPagination" class="flex flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground">

View File

@@ -13,6 +13,8 @@
"@nuxtjs/tailwindcss": "^6.11.4", "@nuxtjs/tailwindcss": "^6.11.4",
"@twilio/voice-sdk": "^2.11.2", "@twilio/voice-sdk": "^2.11.2",
"@vueuse/core": "^10.11.1", "@vueuse/core": "^10.11.1",
"ag-grid-community": "^32.3.4",
"ag-grid-vue3": "^32.3.4",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"gridstack": "^12.4.1", "gridstack": "^12.4.1",
@@ -4976,6 +4978,31 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
} }
}, },
"node_modules/ag-charts-types": {
"version": "10.3.9",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-10.3.9.tgz",
"integrity": "sha512-drcRiJVencliC8LnRwk4MmeQDNNBg5GzmOoLFihO3/k0CUK0VF/N+2nc7iFozwaNG0btSB9vAhYuJLjqHMtRrQ==",
"license": "MIT"
},
"node_modules/ag-grid-community": {
"version": "32.3.9",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-32.3.9.tgz",
"integrity": "sha512-l07SB0mCbGPkC1R8rQQFgBtI5+1FoXBi3RUk1+dHKV/UPeorMEFAzCokcsOhz0qwcWCPrHauUsbRa1SIxfVEJQ==",
"license": "MIT",
"dependencies": {
"ag-charts-types": "10.3.9"
}
},
"node_modules/ag-grid-vue3": {
"version": "32.3.9",
"resolved": "https://registry.npmjs.org/ag-grid-vue3/-/ag-grid-vue3-32.3.9.tgz",
"integrity": "sha512-86Xynbfg8bq/tGW0Yfl2O8HSUgT7nDtNzioMs4r8hNfPiqOfKUUWrTveP5S4Acs8Astr495ZdKKFDIcQVLx3Yg==",
"license": "MIT",
"dependencies": {
"ag-grid-community": "32.3.9",
"vue": "^3.0.0"
}
},
"node_modules/agent-base": { "node_modules/agent-base": {
"version": "7.1.4", "version": "7.1.4",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",

View File

@@ -19,6 +19,8 @@
"@nuxtjs/tailwindcss": "^6.11.4", "@nuxtjs/tailwindcss": "^6.11.4",
"@twilio/voice-sdk": "^2.11.2", "@twilio/voice-sdk": "^2.11.2",
"@vueuse/core": "^10.11.1", "@vueuse/core": "^10.11.1",
"ag-grid-community": "^32.3.4",
"ag-grid-vue3": "^32.3.4",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.1.0", "clsx": "^2.1.0",
"gridstack": "^12.4.1", "gridstack": "^12.4.1",

View File

@@ -42,6 +42,7 @@ const {
fetchRecord, fetchRecord,
deleteRecord, deleteRecord,
deleteRecords, deleteRecords,
updateRecord,
handleSave, handleSave,
} = useViewState(`/runtime/objects/${objectApiName.value}/records`) } = useViewState(`/runtime/objects/${objectApiName.value}/records`)
@@ -212,6 +213,38 @@ const handleLoadMore = async (page: number, pageSize: number) => {
await loadListRecords(page, { append: true, pageSize }) await loadListRecords(page, { append: true, pageSize })
} }
const loadAllListRecords = async () => {
if (!records.value.length) {
await loadListRecords(1)
}
const resolvedTotal = totalCount.value ?? records.value.length
if (resolvedTotal > records.value.length) {
await loadListRecords(1, { append: false, pageSize: resolvedTotal })
}
}
const handleViewChange = async (mode: 'list' | 'spreadsheet') => {
if (mode === 'spreadsheet') {
await loadAllListRecords()
}
}
const handleCellEdit = async (payload: { row: any; field: any; newValue: any; oldValue: any }) => {
if (!payload?.row?.id || payload.newValue === payload.oldValue) return
try {
await updateRecord(payload.row.id, {
...payload.row,
[payload.field.apiName]: payload.newValue,
})
} catch (e: any) {
error.value = e.message || 'Failed to update record'
const record = records.value.find(item => item.id === payload.row.id)
if (record) {
record[payload.field.apiName] = payload.oldValue
}
}
}
// Watch for route changes // Watch for route changes
watch(() => route.params, async (newParams, oldParams) => { watch(() => route.params, async (newParams, oldParams) => {
// Reset current record when navigating to 'new' // Reset current record when navigating to 'new'
@@ -285,6 +318,8 @@ onMounted(async () => {
@delete="handleDelete" @delete="handleDelete"
@page-change="handlePageChange" @page-change="handlePageChange"
@load-more="handleLoadMore" @load-more="handleLoadMore"
@view-change="handleViewChange"
@cell-edit="handleCellEdit"
/> />
<!-- Detail View --> <!-- Detail View -->