WIP - added spredsheet view using ag-grid
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
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 {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -14,7 +18,7 @@ import { Badge } from '@/components/ui/badge'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
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'
|
||||
|
||||
interface Props {
|
||||
@@ -47,6 +51,8 @@ const emit = defineEmits<{
|
||||
'refresh': []
|
||||
'page-change': [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
|
||||
@@ -57,6 +63,7 @@ const sortField = ref<string>('')
|
||||
const sortDirection = ref<'asc' | 'desc'>('asc')
|
||||
const currentPage = ref(1)
|
||||
const bulkAction = ref('delete')
|
||||
const viewMode = ref<'list' | 'spreadsheet'>('list')
|
||||
|
||||
// Computed
|
||||
const visibleFields = computed(() =>
|
||||
@@ -87,7 +94,7 @@ const paginatedData = computed(() => {
|
||||
})
|
||||
const pageStart = computed(() => (props.data.length === 0 ? 0 : startIndex.value + 1))
|
||||
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 canGoNext = computed(() => currentPage.value < availablePages.value)
|
||||
const showLoadMore = computed(() => (
|
||||
@@ -159,6 +166,108 @@ const handleBulkAction = () => {
|
||||
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 nextPage = Math.min(Math.max(page, 1), availablePages.value)
|
||||
if (nextPage !== currentPage.value) {
|
||||
@@ -193,6 +302,13 @@ watch(
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => viewMode.value,
|
||||
(mode) => {
|
||||
emit('view-change', mode)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -216,6 +332,15 @@ watch(
|
||||
</div>
|
||||
|
||||
<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 -->
|
||||
<template v-if="selectedRowIds.length > 0">
|
||||
<Badge variant="secondary" class="px-3 py-1">
|
||||
@@ -264,7 +389,7 @@ watch(
|
||||
|
||||
<!-- Table -->
|
||||
<div class="border rounded-lg">
|
||||
<Table>
|
||||
<Table v-if="viewMode === 'list'">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead v-if="selectable" class="w-12">
|
||||
@@ -338,6 +463,18 @@ watch(
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</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 v-if="showPagination" class="flex flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground">
|
||||
|
||||
27
frontend/package-lock.json
generated
27
frontend/package-lock.json
generated
@@ -13,6 +13,8 @@
|
||||
"@nuxtjs/tailwindcss": "^6.11.4",
|
||||
"@twilio/voice-sdk": "^2.11.2",
|
||||
"@vueuse/core": "^10.11.1",
|
||||
"ag-grid-community": "^32.3.4",
|
||||
"ag-grid-vue3": "^32.3.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"gridstack": "^12.4.1",
|
||||
@@ -4976,6 +4978,31 @@
|
||||
"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": {
|
||||
"version": "7.1.4",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"@nuxtjs/tailwindcss": "^6.11.4",
|
||||
"@twilio/voice-sdk": "^2.11.2",
|
||||
"@vueuse/core": "^10.11.1",
|
||||
"ag-grid-community": "^32.3.4",
|
||||
"ag-grid-vue3": "^32.3.4",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"gridstack": "^12.4.1",
|
||||
|
||||
@@ -42,6 +42,7 @@ const {
|
||||
fetchRecord,
|
||||
deleteRecord,
|
||||
deleteRecords,
|
||||
updateRecord,
|
||||
handleSave,
|
||||
} = useViewState(`/runtime/objects/${objectApiName.value}/records`)
|
||||
|
||||
@@ -212,6 +213,38 @@ const handleLoadMore = async (page: number, pageSize: number) => {
|
||||
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(() => route.params, async (newParams, oldParams) => {
|
||||
// Reset current record when navigating to 'new'
|
||||
@@ -285,6 +318,8 @@ onMounted(async () => {
|
||||
@delete="handleDelete"
|
||||
@page-change="handlePageChange"
|
||||
@load-more="handleLoadMore"
|
||||
@view-change="handleViewChange"
|
||||
@cell-edit="handleCellEdit"
|
||||
/>
|
||||
|
||||
<!-- Detail View -->
|
||||
|
||||
Reference in New Issue
Block a user