Compare commits
2 Commits
eb1619c56c
...
ag-table-t
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
228c3fb704 | ||
|
|
5f14a4050a |
@@ -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, GridApi, GridReadyEvent } 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 {
|
||||||
@@ -25,6 +29,9 @@ interface Props {
|
|||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
totalCount?: number
|
totalCount?: number
|
||||||
searchSummary?: string
|
searchSummary?: string
|
||||||
|
draftEdits?: Record<string, Record<string, any>>
|
||||||
|
cellErrors?: Record<string, Record<string, string | boolean>>
|
||||||
|
savingDrafts?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -33,6 +40,9 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
selectable: false,
|
selectable: false,
|
||||||
baseUrl: '/runtime/objects',
|
baseUrl: '/runtime/objects',
|
||||||
searchSummary: '',
|
searchSummary: '',
|
||||||
|
draftEdits: () => ({}),
|
||||||
|
cellErrors: () => ({}),
|
||||||
|
savingDrafts: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -47,6 +57,10 @@ 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 }]
|
||||||
|
'save-drafts': []
|
||||||
|
'discard-drafts': []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// State
|
// State
|
||||||
@@ -57,6 +71,8 @@ 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')
|
||||||
|
const gridApi = ref<GridApi | null>(null)
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
const visibleFields = computed(() =>
|
const visibleFields = computed(() =>
|
||||||
@@ -87,7 +103,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(() => (
|
||||||
@@ -95,6 +111,8 @@ const showLoadMore = computed(() => (
|
|||||||
Boolean(props.totalCount) &&
|
Boolean(props.totalCount) &&
|
||||||
props.data.length < totalRecords.value
|
props.data.length < totalRecords.value
|
||||||
))
|
))
|
||||||
|
const draftRowCount = computed(() => Object.keys(props.draftEdits).length)
|
||||||
|
const draftCellCount = computed(() => Object.values(props.draftEdits).reduce((sum, row) => sum + Object.keys(row).length, 0))
|
||||||
|
|
||||||
const allSelected = computed({
|
const allSelected = computed({
|
||||||
get: () => props.data.length > 0 && selectedRowIds.value.length === props.data.length,
|
get: () => props.data.length > 0 && selectedRowIds.value.length === props.data.length,
|
||||||
@@ -159,6 +177,154 @@ 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 hasOwnProperty = (target: Record<string, any> | undefined, key: string) =>
|
||||||
|
!!target && Object.prototype.hasOwnProperty.call(target, key)
|
||||||
|
|
||||||
|
const isDraftCell = (params: any) => {
|
||||||
|
const rowId = normalizeId(params?.data?.id)
|
||||||
|
const field = String(params?.colDef?.field || '')
|
||||||
|
return hasOwnProperty(props.draftEdits[rowId], field)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isErrorCell = (params: any) => {
|
||||||
|
const rowId = normalizeId(params?.data?.id)
|
||||||
|
const field = String(params?.colDef?.field || '')
|
||||||
|
return hasOwnProperty(props.cellErrors[rowId], field)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCellClasses = (params: any) => {
|
||||||
|
const classes: string[] = []
|
||||||
|
if (isDraftCell(params)) classes.push('cell-draft')
|
||||||
|
if (isErrorCell(params)) classes.push('cell-error')
|
||||||
|
return classes
|
||||||
|
}
|
||||||
|
|
||||||
|
const getCellStyle = (params: any) => {
|
||||||
|
const isDraft = isDraftCell(params)
|
||||||
|
const isError = isErrorCell(params)
|
||||||
|
if (!isDraft && !isError) return null
|
||||||
|
const style: Record<string, string> = {}
|
||||||
|
if (isDraft) {
|
||||||
|
style.backgroundColor = 'hsl(var(--accent) / 0.45)'
|
||||||
|
style.outline = '2px solid hsl(var(--accent))'
|
||||||
|
style.outlineOffset = '-2px'
|
||||||
|
}
|
||||||
|
if (isError) {
|
||||||
|
style.backgroundColor = 'hsl(var(--destructive) / 0.28)'
|
||||||
|
style.outline = '2px solid hsl(var(--destructive))'
|
||||||
|
style.outlineOffset = '-2px'
|
||||||
|
}
|
||||||
|
return style
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
cellClass: params => getCellClasses(params),
|
||||||
|
cellStyle: params => getCellStyle(params),
|
||||||
|
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 handleGridReady = (event: GridReadyEvent) => {
|
||||||
|
gridApi.value = event.api
|
||||||
|
}
|
||||||
|
|
||||||
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 +359,23 @@ watch(
|
|||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => viewMode.value,
|
||||||
|
(mode) => {
|
||||||
|
emit('view-change', mode)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.draftEdits, props.cellErrors],
|
||||||
|
() => {
|
||||||
|
if (gridApi.value) {
|
||||||
|
gridApi.value.refreshCells({ force: true })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -216,6 +399,35 @@ 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>
|
||||||
|
<template v-if="viewMode === 'spreadsheet'">
|
||||||
|
<Badge v-if="draftRowCount > 0" variant="secondary" class="px-3 py-1">
|
||||||
|
{{ draftCellCount }} change{{ draftCellCount === 1 ? '' : 's' }}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
:disabled="draftRowCount === 0 || savingDrafts"
|
||||||
|
@click="emit('discard-drafts')"
|
||||||
|
>
|
||||||
|
Discard changes
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
:disabled="draftRowCount === 0 || savingDrafts"
|
||||||
|
@click="emit('save-drafts')"
|
||||||
|
>
|
||||||
|
Save changes ({{ draftRowCount }})
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
<!-- 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 +476,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 +550,19 @@ 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"
|
||||||
|
@grid-ready="handleGridReady"
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
@@ -375,4 +600,5 @@ watch(
|
|||||||
.list-view :deep(input) {
|
.list-view :deep(input) {
|
||||||
background-color: hsl(var(--background));
|
background-color: hsl(var(--background));
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
27
frontend/package-lock.json
generated
27
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ const {
|
|||||||
fetchRecord,
|
fetchRecord,
|
||||||
deleteRecord,
|
deleteRecord,
|
||||||
deleteRecords,
|
deleteRecords,
|
||||||
|
updateRecord,
|
||||||
handleSave,
|
handleSave,
|
||||||
} = useViewState(`/runtime/objects/${objectApiName.value}/records`)
|
} = useViewState(`/runtime/objects/${objectApiName.value}/records`)
|
||||||
|
|
||||||
@@ -165,6 +166,12 @@ const deleteDialogOpen = ref(false)
|
|||||||
const deleteSubmitting = ref(false)
|
const deleteSubmitting = ref(false)
|
||||||
const pendingDeleteRows = ref<any[]>([])
|
const pendingDeleteRows = ref<any[]>([])
|
||||||
const deleteSummary = ref<{ deletedIds: string[]; deniedIds: string[] } | null>(null)
|
const deleteSummary = ref<{ deletedIds: string[]; deniedIds: string[] } | null>(null)
|
||||||
|
const normalizeRecordId = (id: any) => String(id)
|
||||||
|
|
||||||
|
const draftEdits = ref<Record<string, Record<string, any>>>({})
|
||||||
|
const draftOriginals = ref<Record<string, Record<string, any>>>({})
|
||||||
|
const cellErrors = ref<Record<string, Record<string, string>>>({})
|
||||||
|
const savingDrafts = ref(false)
|
||||||
|
|
||||||
const isSearchActive = computed(() => searchQuery.value.trim().length > 0)
|
const isSearchActive = computed(() => searchQuery.value.trim().length > 0)
|
||||||
const pendingDeleteCount = computed(() => pendingDeleteRows.value.length)
|
const pendingDeleteCount = computed(() => pendingDeleteRows.value.length)
|
||||||
@@ -355,6 +362,22 @@ 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' && !isSearchActive.value) {
|
||||||
|
await loadAllListRecords()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSearch = async (query: string) => {
|
const handleSearch = async (query: string) => {
|
||||||
const trimmed = query.trim()
|
const trimmed = query.trim()
|
||||||
searchQuery.value = trimmed
|
searchQuery.value = trimmed
|
||||||
@@ -366,6 +389,140 @@ const handleSearch = async (query: string) => {
|
|||||||
await searchListRecords(1, { append: false, pageSize: listPageSize.value })
|
await searchListRecords(1, { append: false, pageSize: listPageSize.value })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCellEdit = async (payload: { row: any; field: any; newValue: any; oldValue: any }) => {
|
||||||
|
if (!payload?.row?.id || payload.newValue === payload.oldValue) return
|
||||||
|
const recordKey = normalizeRecordId(payload.row.id)
|
||||||
|
const fieldName = payload.field.apiName
|
||||||
|
const originalRow = draftOriginals.value[recordKey]
|
||||||
|
const originalValue = originalRow && Object.prototype.hasOwnProperty.call(originalRow, fieldName)
|
||||||
|
? originalRow[fieldName]
|
||||||
|
: payload.oldValue
|
||||||
|
|
||||||
|
if (Object.is(payload.newValue, originalValue)) {
|
||||||
|
const nextDrafts = { ...draftEdits.value }
|
||||||
|
const nextRowDrafts = { ...(nextDrafts[recordKey] || {}) }
|
||||||
|
delete nextRowDrafts[fieldName]
|
||||||
|
if (Object.keys(nextRowDrafts).length === 0) {
|
||||||
|
delete nextDrafts[recordKey]
|
||||||
|
} else {
|
||||||
|
nextDrafts[recordKey] = nextRowDrafts
|
||||||
|
}
|
||||||
|
draftEdits.value = nextDrafts
|
||||||
|
|
||||||
|
const nextOriginals = { ...draftOriginals.value }
|
||||||
|
const nextRowOriginals = { ...(nextOriginals[recordKey] || {}) }
|
||||||
|
delete nextRowOriginals[fieldName]
|
||||||
|
if (Object.keys(nextRowOriginals).length === 0) {
|
||||||
|
delete nextOriginals[recordKey]
|
||||||
|
} else {
|
||||||
|
nextOriginals[recordKey] = nextRowOriginals
|
||||||
|
}
|
||||||
|
draftOriginals.value = nextOriginals
|
||||||
|
} else {
|
||||||
|
draftEdits.value = {
|
||||||
|
...draftEdits.value,
|
||||||
|
[recordKey]: {
|
||||||
|
...(draftEdits.value[recordKey] || {}),
|
||||||
|
[fieldName]: payload.newValue,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (!originalRow || !Object.prototype.hasOwnProperty.call(originalRow, fieldName)) {
|
||||||
|
draftOriginals.value = {
|
||||||
|
...draftOriginals.value,
|
||||||
|
[recordKey]: {
|
||||||
|
...(draftOriginals.value[recordKey] || {}),
|
||||||
|
[fieldName]: payload.oldValue,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cellErrors.value[recordKey]?.[fieldName]) {
|
||||||
|
const nextErrors = { ...cellErrors.value }
|
||||||
|
const nextRowErrors = { ...(nextErrors[recordKey] || {}) }
|
||||||
|
delete nextRowErrors[fieldName]
|
||||||
|
if (Object.keys(nextRowErrors).length === 0) {
|
||||||
|
delete nextErrors[recordKey]
|
||||||
|
} else {
|
||||||
|
nextErrors[recordKey] = nextRowErrors
|
||||||
|
}
|
||||||
|
cellErrors.value = nextErrors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveDrafts = async () => {
|
||||||
|
if (Object.keys(draftEdits.value).length === 0) return
|
||||||
|
savingDrafts.value = true
|
||||||
|
const nextErrors: Record<string, Record<string, string>> = {}
|
||||||
|
const nextDrafts = { ...draftEdits.value }
|
||||||
|
const nextOriginals = { ...draftOriginals.value }
|
||||||
|
|
||||||
|
for (const [recordKey, changes] of Object.entries(draftEdits.value)) {
|
||||||
|
const record = records.value.find(item => normalizeRecordId(item.id) === recordKey)
|
||||||
|
if (!record) {
|
||||||
|
delete nextDrafts[recordKey]
|
||||||
|
delete nextOriginals[recordKey]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateRecord(record.id, changes)
|
||||||
|
delete nextDrafts[recordKey]
|
||||||
|
delete nextOriginals[recordKey]
|
||||||
|
} catch (e: any) {
|
||||||
|
const message = e.message || 'Failed to update record'
|
||||||
|
nextErrors[recordKey] = {}
|
||||||
|
for (const fieldName of Object.keys(changes)) {
|
||||||
|
nextErrors[recordKey][fieldName] = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draftEdits.value = nextDrafts
|
||||||
|
draftOriginals.value = nextOriginals
|
||||||
|
cellErrors.value = nextErrors
|
||||||
|
savingDrafts.value = false
|
||||||
|
if (Object.keys(nextErrors).length > 0) {
|
||||||
|
error.value = 'Some updates failed. Fix highlighted cells and try again.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDiscardDrafts = () => {
|
||||||
|
for (const [recordKey, fields] of Object.entries(draftOriginals.value)) {
|
||||||
|
const record = records.value.find(item => normalizeRecordId(item.id) === recordKey)
|
||||||
|
if (!record) continue
|
||||||
|
for (const [fieldName, originalValue] of Object.entries(fields)) {
|
||||||
|
record[fieldName] = originalValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draftEdits.value = {}
|
||||||
|
draftOriginals.value = {}
|
||||||
|
cellErrors.value = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => records.value.map(record => normalizeRecordId(record.id)),
|
||||||
|
(ids) => {
|
||||||
|
const idSet = new Set(ids)
|
||||||
|
const nextDrafts: Record<string, Record<string, any>> = {}
|
||||||
|
const nextOriginals: Record<string, Record<string, any>> = {}
|
||||||
|
const nextErrors: Record<string, Record<string, string>> = {}
|
||||||
|
|
||||||
|
for (const [recordKey, fields] of Object.entries(draftEdits.value)) {
|
||||||
|
if (idSet.has(recordKey)) nextDrafts[recordKey] = fields
|
||||||
|
}
|
||||||
|
for (const [recordKey, fields] of Object.entries(draftOriginals.value)) {
|
||||||
|
if (idSet.has(recordKey)) nextOriginals[recordKey] = fields
|
||||||
|
}
|
||||||
|
for (const [recordKey, fields] of Object.entries(cellErrors.value)) {
|
||||||
|
if (idSet.has(recordKey)) nextErrors[recordKey] = fields
|
||||||
|
}
|
||||||
|
|
||||||
|
draftEdits.value = nextDrafts
|
||||||
|
draftOriginals.value = nextOriginals
|
||||||
|
cellErrors.value = nextErrors
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 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'
|
||||||
@@ -438,6 +595,9 @@ onMounted(async () => {
|
|||||||
:total-count="totalCount"
|
:total-count="totalCount"
|
||||||
:search-summary="searchSummary"
|
:search-summary="searchSummary"
|
||||||
:base-url="`/runtime/objects`"
|
:base-url="`/runtime/objects`"
|
||||||
|
:draft-edits="draftEdits"
|
||||||
|
:cell-errors="cellErrors"
|
||||||
|
:saving-drafts="savingDrafts"
|
||||||
selectable
|
selectable
|
||||||
@row-click="handleRowClick"
|
@row-click="handleRowClick"
|
||||||
@create="handleCreate"
|
@create="handleCreate"
|
||||||
@@ -446,6 +606,10 @@ onMounted(async () => {
|
|||||||
@search="handleSearch"
|
@search="handleSearch"
|
||||||
@page-change="handlePageChange"
|
@page-change="handlePageChange"
|
||||||
@load-more="handleLoadMore"
|
@load-more="handleLoadMore"
|
||||||
|
@view-change="handleViewChange"
|
||||||
|
@cell-edit="handleCellEdit"
|
||||||
|
@save-drafts="handleSaveDrafts"
|
||||||
|
@discard-drafts="handleDiscardDrafts"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Detail View -->
|
<!-- Detail View -->
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|
||||||
@@ -90,6 +91,12 @@ const editConfig = computed(() => {
|
|||||||
|
|
||||||
const listPageSize = computed(() => listConfig.value?.pageSize ?? 25)
|
const listPageSize = computed(() => listConfig.value?.pageSize ?? 25)
|
||||||
const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?? 500)
|
const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?? 500)
|
||||||
|
const normalizeRecordId = (id: any) => String(id)
|
||||||
|
|
||||||
|
const draftEdits = ref<Record<string, Record<string, any>>>({})
|
||||||
|
const draftOriginals = ref<Record<string, Record<string, any>>>({})
|
||||||
|
const cellErrors = ref<Record<string, Record<string, string>>>({})
|
||||||
|
const savingDrafts = ref(false)
|
||||||
|
|
||||||
// Fetch object definition
|
// Fetch object definition
|
||||||
const fetchObjectDefinition = async () => {
|
const fetchObjectDefinition = async () => {
|
||||||
@@ -212,6 +219,156 @@ 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
|
||||||
|
const recordKey = normalizeRecordId(payload.row.id)
|
||||||
|
const fieldName = payload.field.apiName
|
||||||
|
const originalRow = draftOriginals.value[recordKey]
|
||||||
|
const originalValue = originalRow && Object.prototype.hasOwnProperty.call(originalRow, fieldName)
|
||||||
|
? originalRow[fieldName]
|
||||||
|
: payload.oldValue
|
||||||
|
|
||||||
|
if (Object.is(payload.newValue, originalValue)) {
|
||||||
|
const nextDrafts = { ...draftEdits.value }
|
||||||
|
const nextRowDrafts = { ...(nextDrafts[recordKey] || {}) }
|
||||||
|
delete nextRowDrafts[fieldName]
|
||||||
|
if (Object.keys(nextRowDrafts).length === 0) {
|
||||||
|
delete nextDrafts[recordKey]
|
||||||
|
} else {
|
||||||
|
nextDrafts[recordKey] = nextRowDrafts
|
||||||
|
}
|
||||||
|
draftEdits.value = nextDrafts
|
||||||
|
|
||||||
|
const nextOriginals = { ...draftOriginals.value }
|
||||||
|
const nextRowOriginals = { ...(nextOriginals[recordKey] || {}) }
|
||||||
|
delete nextRowOriginals[fieldName]
|
||||||
|
if (Object.keys(nextRowOriginals).length === 0) {
|
||||||
|
delete nextOriginals[recordKey]
|
||||||
|
} else {
|
||||||
|
nextOriginals[recordKey] = nextRowOriginals
|
||||||
|
}
|
||||||
|
draftOriginals.value = nextOriginals
|
||||||
|
} else {
|
||||||
|
draftEdits.value = {
|
||||||
|
...draftEdits.value,
|
||||||
|
[recordKey]: {
|
||||||
|
...(draftEdits.value[recordKey] || {}),
|
||||||
|
[fieldName]: payload.newValue,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (!originalRow || !Object.prototype.hasOwnProperty.call(originalRow, fieldName)) {
|
||||||
|
draftOriginals.value = {
|
||||||
|
...draftOriginals.value,
|
||||||
|
[recordKey]: {
|
||||||
|
...(draftOriginals.value[recordKey] || {}),
|
||||||
|
[fieldName]: payload.oldValue,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cellErrors.value[recordKey]?.[fieldName]) {
|
||||||
|
const nextErrors = { ...cellErrors.value }
|
||||||
|
const nextRowErrors = { ...(nextErrors[recordKey] || {}) }
|
||||||
|
delete nextRowErrors[fieldName]
|
||||||
|
if (Object.keys(nextRowErrors).length === 0) {
|
||||||
|
delete nextErrors[recordKey]
|
||||||
|
} else {
|
||||||
|
nextErrors[recordKey] = nextRowErrors
|
||||||
|
}
|
||||||
|
cellErrors.value = nextErrors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaveDrafts = async () => {
|
||||||
|
if (Object.keys(draftEdits.value).length === 0) return
|
||||||
|
savingDrafts.value = true
|
||||||
|
const nextErrors: Record<string, Record<string, string>> = {}
|
||||||
|
const nextDrafts = { ...draftEdits.value }
|
||||||
|
const nextOriginals = { ...draftOriginals.value }
|
||||||
|
|
||||||
|
for (const [recordKey, changes] of Object.entries(draftEdits.value)) {
|
||||||
|
const record = records.value.find(item => normalizeRecordId(item.id) === recordKey)
|
||||||
|
if (!record) {
|
||||||
|
delete nextDrafts[recordKey]
|
||||||
|
delete nextOriginals[recordKey]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await updateRecord(record.id, changes)
|
||||||
|
delete nextDrafts[recordKey]
|
||||||
|
delete nextOriginals[recordKey]
|
||||||
|
} catch (e: any) {
|
||||||
|
const message = e.message || 'Failed to update record'
|
||||||
|
nextErrors[recordKey] = {}
|
||||||
|
for (const fieldName of Object.keys(changes)) {
|
||||||
|
nextErrors[recordKey][fieldName] = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draftEdits.value = nextDrafts
|
||||||
|
draftOriginals.value = nextOriginals
|
||||||
|
cellErrors.value = nextErrors
|
||||||
|
savingDrafts.value = false
|
||||||
|
if (Object.keys(nextErrors).length > 0) {
|
||||||
|
error.value = 'Some updates failed. Fix highlighted cells and try again.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDiscardDrafts = () => {
|
||||||
|
for (const [recordKey, fields] of Object.entries(draftOriginals.value)) {
|
||||||
|
const record = records.value.find(item => normalizeRecordId(item.id) === recordKey)
|
||||||
|
if (!record) continue
|
||||||
|
for (const [fieldName, originalValue] of Object.entries(fields)) {
|
||||||
|
record[fieldName] = originalValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
draftEdits.value = {}
|
||||||
|
draftOriginals.value = {}
|
||||||
|
cellErrors.value = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => records.value.map(record => normalizeRecordId(record.id)),
|
||||||
|
(ids) => {
|
||||||
|
const idSet = new Set(ids)
|
||||||
|
const nextDrafts: Record<string, Record<string, any>> = {}
|
||||||
|
const nextOriginals: Record<string, Record<string, any>> = {}
|
||||||
|
const nextErrors: Record<string, Record<string, string>> = {}
|
||||||
|
|
||||||
|
for (const [recordKey, fields] of Object.entries(draftEdits.value)) {
|
||||||
|
if (idSet.has(recordKey)) nextDrafts[recordKey] = fields
|
||||||
|
}
|
||||||
|
for (const [recordKey, fields] of Object.entries(draftOriginals.value)) {
|
||||||
|
if (idSet.has(recordKey)) nextOriginals[recordKey] = fields
|
||||||
|
}
|
||||||
|
for (const [recordKey, fields] of Object.entries(cellErrors.value)) {
|
||||||
|
if (idSet.has(recordKey)) nextErrors[recordKey] = fields
|
||||||
|
}
|
||||||
|
|
||||||
|
draftEdits.value = nextDrafts
|
||||||
|
draftOriginals.value = nextOriginals
|
||||||
|
cellErrors.value = nextErrors
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
// 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'
|
||||||
@@ -278,6 +435,9 @@ onMounted(async () => {
|
|||||||
:data="records"
|
:data="records"
|
||||||
:loading="dataLoading"
|
:loading="dataLoading"
|
||||||
:total-count="totalCount"
|
:total-count="totalCount"
|
||||||
|
:draft-edits="draftEdits"
|
||||||
|
:cell-errors="cellErrors"
|
||||||
|
:saving-drafts="savingDrafts"
|
||||||
selectable
|
selectable
|
||||||
@row-click="handleRowClick"
|
@row-click="handleRowClick"
|
||||||
@create="handleCreate"
|
@create="handleCreate"
|
||||||
@@ -285,6 +445,10 @@ onMounted(async () => {
|
|||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
@page-change="handlePageChange"
|
@page-change="handlePageChange"
|
||||||
@load-more="handleLoadMore"
|
@load-more="handleLoadMore"
|
||||||
|
@view-change="handleViewChange"
|
||||||
|
@cell-edit="handleCellEdit"
|
||||||
|
@save-drafts="handleSaveDrafts"
|
||||||
|
@discard-drafts="handleDiscardDrafts"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Detail View -->
|
<!-- Detail View -->
|
||||||
|
|||||||
Reference in New Issue
Block a user