diff --git a/frontend/components/views/ListView.vue b/frontend/components/views/ListView.vue
index b271428..f07b59b 100644
--- a/frontend/components/views/ListView.vue
+++ b/frontend/components/views/ListView.vue
@@ -1,6 +1,6 @@
@@ -341,6 +408,26 @@ watch(
Spreadsheet view
+
+
+ {{ draftCellCount }} change{{ draftCellCount === 1 ? '' : 's' }}
+
+
+
+
@@ -473,6 +560,7 @@ watch(
suppress-row-click-selection
single-click-edit
@cell-value-changed="handleCellValueChanged"
+ @grid-ready="handleGridReady"
/>
@@ -512,4 +600,5 @@ watch(
.list-view :deep(input) {
background-color: hsl(var(--background));
}
+
diff --git a/frontend/pages/[objectName]/[[recordId]]/[[view]].vue b/frontend/pages/[objectName]/[[recordId]]/[[view]].vue
index 0dc4cad..24f2e5c 100644
--- a/frontend/pages/[objectName]/[[recordId]]/[[view]].vue
+++ b/frontend/pages/[objectName]/[[recordId]]/[[view]].vue
@@ -57,6 +57,7 @@ const {
fetchRecord,
deleteRecord,
deleteRecords,
+ updateRecord,
handleSave,
} = useViewState(`/runtime/objects/${objectApiName.value}/records`)
@@ -165,6 +166,12 @@ const deleteDialogOpen = ref(false)
const deleteSubmitting = ref(false)
const pendingDeleteRows = ref([])
const deleteSummary = ref<{ deletedIds: string[]; deniedIds: string[] } | null>(null)
+const normalizeRecordId = (id: any) => String(id)
+
+const draftEdits = ref>>({})
+const draftOriginals = ref>>({})
+const cellErrors = ref>>({})
+const savingDrafts = ref(false)
const isSearchActive = computed(() => searchQuery.value.trim().length > 0)
const pendingDeleteCount = computed(() => pendingDeleteRows.value.length)
@@ -355,6 +362,22 @@ 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' && !isSearchActive.value) {
+ await loadAllListRecords()
+ }
+}
+
const handleSearch = async (query: string) => {
const trimmed = query.trim()
searchQuery.value = trimmed
@@ -366,6 +389,140 @@ const handleSearch = async (query: string) => {
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> = {}
+ 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> = {}
+ const nextOriginals: Record> = {}
+ const nextErrors: Record> = {}
+
+ 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(() => route.params, async (newParams, oldParams) => {
// Reset current record when navigating to 'new'
@@ -438,6 +595,9 @@ onMounted(async () => {
:total-count="totalCount"
:search-summary="searchSummary"
:base-url="`/runtime/objects`"
+ :draft-edits="draftEdits"
+ :cell-errors="cellErrors"
+ :saving-drafts="savingDrafts"
selectable
@row-click="handleRowClick"
@create="handleCreate"
@@ -446,6 +606,10 @@ onMounted(async () => {
@search="handleSearch"
@page-change="handlePageChange"
@load-more="handleLoadMore"
+ @view-change="handleViewChange"
+ @cell-edit="handleCellEdit"
+ @save-drafts="handleSaveDrafts"
+ @discard-drafts="handleDiscardDrafts"
/>
diff --git a/frontend/pages/app/objects/[objectName]/[[recordId]]/[[view]].vue b/frontend/pages/app/objects/[objectName]/[[recordId]]/[[view]].vue
index 2328c16..1ab625c 100644
--- a/frontend/pages/app/objects/[objectName]/[[recordId]]/[[view]].vue
+++ b/frontend/pages/app/objects/[objectName]/[[recordId]]/[[view]].vue
@@ -91,6 +91,12 @@ const editConfig = computed(() => {
const listPageSize = computed(() => listConfig.value?.pageSize ?? 25)
const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?? 500)
+const normalizeRecordId = (id: any) => String(id)
+
+const draftEdits = ref>>({})
+const draftOriginals = ref>>({})
+const cellErrors = ref>>({})
+const savingDrafts = ref(false)
// Fetch object definition
const fetchObjectDefinition = async () => {
@@ -231,20 +237,138 @@ const handleViewChange = async (mode: 'list' | 'spreadsheet') => {
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
+ 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> = {}
+ 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> = {}
+ const nextOriginals: Record> = {}
+ const nextErrors: Record> = {}
+
+ 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(() => route.params, async (newParams, oldParams) => {
// Reset current record when navigating to 'new'
@@ -311,6 +435,9 @@ onMounted(async () => {
:data="records"
:loading="dataLoading"
:total-count="totalCount"
+ :draft-edits="draftEdits"
+ :cell-errors="cellErrors"
+ :saving-drafts="savingDrafts"
selectable
@row-click="handleRowClick"
@create="handleCreate"
@@ -320,6 +447,8 @@ onMounted(async () => {
@load-more="handleLoadMore"
@view-change="handleViewChange"
@cell-edit="handleCellEdit"
+ @save-drafts="handleSaveDrafts"
+ @discard-drafts="handleDiscardDrafts"
/>