WIP - Added pagination for list view

This commit is contained in:
Francisco Gaona
2026-01-13 09:03:11 +01:00
parent 730fddd181
commit 47fa72451d
9 changed files with 357 additions and 22 deletions

View File

@@ -576,11 +576,48 @@ export class ObjectService {
} }
} }
// Apply additional filters // Extract pagination and sorting parameters from query string
if (filters) { const {
query = query.where(filters); page,
pageSize,
sortField,
sortDirection,
...rawFilters
} = filters || {};
const reservedFilterKeys = new Set(['page', 'pageSize', 'sortField', 'sortDirection']);
const filterEntries = Object.entries(rawFilters || {}).filter(
([key, value]) =>
!reservedFilterKeys.has(key) &&
value !== undefined &&
value !== null &&
value !== '',
);
if (filterEntries.length > 0) {
query = query.where(builder => {
for (const [key, value] of filterEntries) {
builder.where(key, value as any);
}
});
} }
if (sortField) {
query = query.orderBy(sortField, sortDirection === 'desc' ? 'desc' : 'asc');
}
const parsedPage = Number.isFinite(Number(page)) ? Number(page) : 1;
const parsedPageSize = Number.isFinite(Number(pageSize)) ? Number(pageSize) : 0;
const safePage = parsedPage > 0 ? parsedPage : 1;
const safePageSize = parsedPageSize > 0 ? Math.min(parsedPageSize, 500) : 0;
const shouldPaginate = safePageSize > 0;
const totalCount = await query.clone().resultSize();
if (shouldPaginate) {
query = query.offset((safePage - 1) * safePageSize).limit(safePageSize);
}
const records = await query.select('*'); const records = await query.select('*');
// Filter fields based on field-level permissions // Filter fields based on field-level permissions
@@ -590,7 +627,12 @@ export class ObjectService {
) )
); );
return filteredRecords; return {
data: filteredRecords,
totalCount,
page: shouldPaginate ? safePage : 1,
pageSize: shouldPaginate ? safePageSize : filteredRecords.length,
};
} }
async getRecord( async getRecord(
@@ -952,6 +994,73 @@ export class ObjectService {
return { success: true }; return { success: true };
} }
async deleteRecords(
tenantId: string,
objectApiName: string,
recordIds: string[],
userId: string,
) {
if (!Array.isArray(recordIds) || recordIds.length === 0) {
throw new BadRequestException('No record IDs provided');
}
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Get user with roles and permissions
const user = await User.query(knex)
.findById(userId)
.withGraphFetched('[roles.[objectPermissions, fieldPermissions]]');
if (!user) {
throw new NotFoundException('User not found');
}
// Get object definition with authorization settings
const objectDefModel = await ObjectDefinition.query(knex)
.findOne({ apiName: objectApiName });
if (!objectDefModel) {
throw new NotFoundException(`Object ${objectApiName} not found`);
}
const tableName = this.getTableName(
objectDefModel.apiName,
objectDefModel.label,
objectDefModel.pluralLabel,
);
const records = await knex(tableName).whereIn('id', recordIds);
if (records.length === 0) {
throw new NotFoundException('No records found to delete');
}
const foundIds = new Set(records.map((record: any) => record.id));
const missingIds = recordIds.filter(id => !foundIds.has(id));
if (missingIds.length > 0) {
throw new NotFoundException(`Records not found: ${missingIds.join(', ')}`);
}
// Check if user can delete each record
for (const record of records) {
await this.authService.assertCanPerformAction('delete', objectDefModel, record, user, knex);
}
// Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
// Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
await boundModel.query().whereIn('id', recordIds).delete();
// Remove from search index
await Promise.all(
recordIds.map((id) => this.removeIndexedRecord(resolvedTenantId, objectApiName, id)),
);
return { success: true, deleted: recordIds.length };
}
private async indexRecord( private async indexRecord(
tenantId: string, tenantId: string,
objectApiName: string, objectApiName: string,

View File

@@ -95,4 +95,20 @@ export class RuntimeObjectController {
user.userId, user.userId,
); );
} }
@Post(':objectApiName/records/bulk-delete')
async deleteRecords(
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
@Body() body: { recordIds?: string[]; ids?: string[] },
@CurrentUser() user: any,
) {
const recordIds: string[] = body?.recordIds || body?.ids || [];
return this.objectService.deleteRecords(
tenantId,
objectApiName,
recordIds,
user.userId,
);
}
} }

View File

@@ -78,7 +78,9 @@ const fetchRecords = async () => {
try { try {
const endpoint = `${props.baseUrl}/${relationObject.value}/records` const endpoint = `${props.baseUrl}/${relationObject.value}/records`
const response = await api.get(endpoint) const response = await api.get(endpoint)
records.value = response || [] records.value = Array.isArray(response)
? response
: response?.data || response?.records || []
// If we have a modelValue, find the selected record // If we have a modelValue, find the selected record
if (props.modelValue) { if (props.modelValue) {

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, watch } from 'vue'
import { import {
Table, Table,
TableBody, TableBody,
@@ -22,6 +22,7 @@ interface Props {
loading?: boolean loading?: boolean
selectable?: boolean selectable?: boolean
baseUrl?: string baseUrl?: string
totalCount?: number
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -41,6 +42,8 @@ const emit = defineEmits<{
'sort': [field: string, direction: 'asc' | 'desc'] 'sort': [field: string, direction: 'asc' | 'desc']
'search': [query: string] 'search': [query: string]
'refresh': [] 'refresh': []
'page-change': [page: number, pageSize: number]
'load-more': [page: number, pageSize: number]
}>() }>()
// State // State
@@ -48,12 +51,46 @@ const selectedRows = ref<Set<string>>(new Set())
const searchQuery = ref('') const searchQuery = ref('')
const sortField = ref<string>('') const sortField = ref<string>('')
const sortDirection = ref<'asc' | 'desc'>('asc') const sortDirection = ref<'asc' | 'desc'>('asc')
const currentPage = ref(1)
// Computed // Computed
const visibleFields = computed(() => const visibleFields = computed(() =>
props.config.fields.filter(f => f.showOnList !== false) props.config.fields.filter(f => f.showOnList !== false)
) )
const pageSize = computed(() => props.config.pageSize ?? 10)
const maxFrontendRecords = computed(() => props.config.maxFrontendRecords ?? 500)
const totalRecords = computed(() =>
(props.totalCount && props.totalCount > 0)
? props.totalCount
: props.data.length
)
const useHybridPagination = computed(() => totalRecords.value > maxFrontendRecords.value)
const totalPages = computed(() => Math.max(1, Math.ceil(totalRecords.value / pageSize.value)))
const loadedPages = computed(() => Math.max(1, Math.ceil(props.data.length / pageSize.value)))
const availablePages = computed(() => {
if (useHybridPagination.value && props.totalCount && props.data.length < props.totalCount) {
return loadedPages.value
}
return totalPages.value
})
const startIndex = computed(() => (currentPage.value - 1) * pageSize.value)
const paginatedData = computed(() => {
const start = startIndex.value
const end = start + pageSize.value
return props.data.slice(start, end)
})
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 canGoPrev = computed(() => currentPage.value > 1)
const canGoNext = computed(() => currentPage.value < availablePages.value)
const showLoadMore = computed(() => (
useHybridPagination.value &&
Boolean(props.totalCount) &&
props.data.length < totalRecords.value
))
const allSelected = computed({ const allSelected = computed({
get: () => props.data.length > 0 && selectedRows.value.size === props.data.length, get: () => props.data.length > 0 && selectedRows.value.size === props.data.length,
set: (val: boolean) => { set: (val: boolean) => {
@@ -96,6 +133,28 @@ const handleSearch = () => {
const handleAction = (actionId: string) => { const handleAction = (actionId: string) => {
emit('action', actionId, getSelectedRows()) emit('action', actionId, getSelectedRows())
} }
const goToPage = (page: number) => {
const nextPage = Math.min(Math.max(page, 1), availablePages.value)
if (nextPage !== currentPage.value) {
currentPage.value = nextPage
emit('page-change', nextPage, pageSize.value)
}
}
const loadMore = () => {
const nextPage = Math.ceil(props.data.length / pageSize.value) + 1
emit('load-more', nextPage, pageSize.value)
}
watch(
() => [props.data.length, totalRecords.value, pageSize.value],
() => {
if (currentPage.value > availablePages.value) {
currentPage.value = availablePages.value
}
}
)
</script> </script>
<template> <template>
@@ -192,7 +251,7 @@ const handleAction = (actionId: string) => {
</TableRow> </TableRow>
<TableRow <TableRow
v-else v-else
v-for="row in data" v-for="row in paginatedData"
:key="row.id" :key="row.id"
class="cursor-pointer hover:bg-muted/50" class="cursor-pointer hover:bg-muted/50"
@click="emit('row-click', row)" @click="emit('row-click', row)"
@@ -227,7 +286,26 @@ const handleAction = (actionId: string) => {
</Table> </Table>
</div> </div>
<!-- Pagination would go here --> <div v-if="showPagination" class="flex flex-wrap items-center justify-between gap-3 text-sm text-muted-foreground">
<div class="flex items-center gap-2">
<span>Showing {{ pageStart }}-{{ pageEnd }} of {{ totalRecords }} records</span>
<span v-if="showLoadMore">
(loaded {{ data.length }})
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" :disabled="!canGoPrev" @click="goToPage(currentPage - 1)">
Previous
</Button>
<span class="px-2">Page {{ currentPage }} of {{ totalPages }}</span>
<Button variant="outline" size="sm" :disabled="!canGoNext" @click="goToPage(currentPage + 1)">
Next
</Button>
<Button v-if="showLoadMore" variant="secondary" size="sm" @click="loadMore">
Load more
</Button>
</div>
</div>
</div> </div>
</template> </template>

View File

@@ -78,7 +78,8 @@ export const useFields = () => {
objectApiName: objectDef.apiName, objectApiName: objectDef.apiName,
mode: 'list' as ViewMode, mode: 'list' as ViewMode,
fields, fields,
pageSize: 25, pageSize: 10,
maxFrontendRecords: 500,
searchable: true, searchable: true,
filterable: true, filterable: true,
exportable: true, exportable: true,
@@ -183,6 +184,7 @@ export const useViewState = <T extends { id?: string }>(
apiEndpoint: string apiEndpoint: string
) => { ) => {
const records = ref<T[]>([]) const records = ref<T[]>([])
const totalCount = ref(0)
const currentRecord = ref<T | null>(null) const currentRecord = ref<T | null>(null)
const currentView = ref<'list' | 'detail' | 'edit'>('list') const currentView = ref<'list' | 'detail' | 'edit'>('list')
const loading = ref(false) const loading = ref(false)
@@ -191,13 +193,51 @@ export const useViewState = <T extends { id?: string }>(
const { api } = useApi() const { api } = useApi()
const fetchRecords = async (params?: Record<string, any>) => { const normalizeListResponse = (response: any) => {
const payload: { data: T[]; totalCount: number; page?: number; pageSize?: number } = {
data: [],
totalCount: 0,
}
if (Array.isArray(response)) {
payload.data = response
payload.totalCount = response.length
return payload
}
if (response && typeof response === 'object') {
if (Array.isArray(response.data)) {
payload.data = response.data
} else if (Array.isArray((response as any).records)) {
payload.data = (response as any).records
} else if (Array.isArray((response as any).results)) {
payload.data = (response as any).results
}
payload.totalCount =
response.totalCount ??
response.total ??
response.count ??
payload.data.length ??
0
payload.page = response.page
payload.pageSize = response.pageSize
}
return payload
}
const fetchRecords = async (params?: Record<string, any>, options?: { append?: boolean }) => {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const response = await api.get(apiEndpoint, { params }) const response = await api.get(apiEndpoint, { params })
// Handle response - data might be directly in response or in response.data const normalized = normalizeListResponse(response)
records.value = response.data || response || [] totalCount.value = normalized.totalCount ?? normalized.data.length ?? 0
records.value = options?.append
? [...records.value, ...normalized.data]
: normalized.data
return normalized
} catch (e: any) { } catch (e: any) {
error.value = e.message error.value = e.message
console.error('Failed to fetch records:', e) console.error('Failed to fetch records:', e)
@@ -230,6 +270,7 @@ export const useViewState = <T extends { id?: string }>(
// Handle response - it might be the data directly or wrapped in { data: ... } // Handle response - it might be the data directly or wrapped in { data: ... }
const recordData = response.data || response const recordData = response.data || response
records.value.push(recordData) records.value.push(recordData)
totalCount.value += 1
currentRecord.value = recordData currentRecord.value = recordData
return recordData return recordData
} catch (e: any) { } catch (e: any) {
@@ -272,6 +313,7 @@ export const useViewState = <T extends { id?: string }>(
try { try {
await api.delete(`${apiEndpoint}/${id}`) await api.delete(`${apiEndpoint}/${id}`)
records.value = records.value.filter(r => r.id !== id) records.value = records.value.filter(r => r.id !== id)
totalCount.value = Math.max(0, totalCount.value - 1)
if (currentRecord.value?.id === id) { if (currentRecord.value?.id === id) {
currentRecord.value = null currentRecord.value = null
} }
@@ -290,6 +332,7 @@ export const useViewState = <T extends { id?: string }>(
try { try {
await Promise.all(ids.map(id => api.delete(`${apiEndpoint}/${id}`))) await Promise.all(ids.map(id => api.delete(`${apiEndpoint}/${id}`)))
records.value = records.value.filter(r => !ids.includes(r.id!)) records.value = records.value.filter(r => !ids.includes(r.id!))
totalCount.value = Math.max(0, totalCount.value - ids.length)
} catch (e: any) { } catch (e: any) {
error.value = e.message error.value = e.message
console.error('Failed to delete records:', e) console.error('Failed to delete records:', e)
@@ -327,6 +370,7 @@ export const useViewState = <T extends { id?: string }>(
return { return {
// State // State
records, records,
totalCount,
currentRecord, currentRecord,
currentView, currentView,
loading, loading,

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { useFields, useViewState } from '@/composables/useFieldViews' import { useFields, useViewState } from '@/composables/useFieldViews'
@@ -38,6 +38,7 @@ const error = ref<string | null>(null)
// Use view state composable // Use view state composable
const { const {
records, records,
totalCount,
currentRecord, currentRecord,
loading: dataLoading, loading: dataLoading,
saving, saving,
@@ -57,7 +58,7 @@ const handleAiRecordCreated = (event: Event) => {
return return
} }
if (view.value === 'list') { if (view.value === 'list') {
fetchRecords() initializeListRecords()
} }
} }
@@ -142,6 +143,9 @@ const editConfig = computed(() => {
return buildEditViewConfig(objectDefinition.value) return buildEditViewConfig(objectDefinition.value)
}) })
const listPageSize = computed(() => listConfig.value?.pageSize ?? 25)
const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?? 500)
// Fetch object definition // Fetch object definition
const fetchObjectDefinition = async () => { const fetchObjectDefinition = async () => {
try { try {
@@ -220,6 +224,39 @@ const handleCancel = () => {
} }
} }
const loadListRecords = async (
page = 1,
options?: { append?: boolean; pageSize?: number }
) => {
const pageSize = options?.pageSize ?? listPageSize.value
const result = await fetchRecords({ page, pageSize }, { append: options?.append })
const resolvedTotal = result?.totalCount ?? totalCount.value ?? records.value.length
totalCount.value = resolvedTotal
return result
}
const initializeListRecords = async () => {
const firstResult = await loadListRecords(1)
const resolvedTotal = firstResult?.totalCount ?? totalCount.value ?? records.value.length
const shouldPrefetchAll =
resolvedTotal <= maxFrontendRecords.value && records.value.length < resolvedTotal
if (shouldPrefetchAll) {
await loadListRecords(1, { append: false, pageSize: maxFrontendRecords.value })
}
}
const handlePageChange = async (page: number, pageSize: number) => {
const loadedPages = Math.ceil(records.value.length / pageSize)
if (page > loadedPages && totalCount.value > records.value.length) {
await loadListRecords(page, { append: true, pageSize })
}
}
const handleLoadMore = async (page: number, pageSize: number) => {
await loadListRecords(page, { append: true, pageSize })
}
// 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'
@@ -234,7 +271,7 @@ watch(() => route.params, async (newParams, oldParams) => {
// Fetch records if navigating back to list // Fetch records if navigating back to list
if (!newParams.recordId && !newParams.view) { if (!newParams.recordId && !newParams.view) {
await fetchRecords() await initializeListRecords()
} }
}, { deep: true }) }, { deep: true })
@@ -243,7 +280,7 @@ onMounted(async () => {
await fetchObjectDefinition() await fetchObjectDefinition()
if (view.value === 'list') { if (view.value === 'list') {
await fetchRecords() await initializeListRecords()
} else if (recordId.value && recordId.value !== 'new') { } else if (recordId.value && recordId.value !== 'new') {
await fetchRecord(recordId.value) await fetchRecord(recordId.value)
} }
@@ -289,12 +326,15 @@ onMounted(async () => {
:config="listConfig" :config="listConfig"
:data="records" :data="records"
:loading="dataLoading" :loading="dataLoading"
:total-count="totalCount"
:base-url="`/runtime/objects`" :base-url="`/runtime/objects`"
selectable selectable
@row-click="handleRowClick" @row-click="handleRowClick"
@create="handleCreate" @create="handleCreate"
@edit="handleEdit" @edit="handleEdit"
@delete="handleDelete" @delete="handleDelete"
@page-change="handlePageChange"
@load-more="handleLoadMore"
/> />
<!-- Detail View --> <!-- Detail View -->

View File

@@ -71,7 +71,12 @@ const fetchPage = async () => {
if (page.value.objectApiName) { if (page.value.objectApiName) {
loadingRecords.value = true loadingRecords.value = true
records.value = await api.get(`/runtime/objects/${page.value.objectApiName}/records`) const response = await api.get(
`/runtime/objects/${page.value.objectApiName}/records`
)
records.value = Array.isArray(response)
? response
: response?.data || response?.records || []
} }
} catch (e: any) { } catch (e: any) {
error.value = e.message error.value = e.message

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch, nextTick } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useApi } from '@/composables/useApi' import { useApi } from '@/composables/useApi'
import { useFields, useViewState } from '@/composables/useFieldViews' import { useFields, useViewState } from '@/composables/useFieldViews'
@@ -31,6 +31,7 @@ const error = ref<string | null>(null)
// Use view state composable // Use view state composable
const { const {
records, records,
totalCount,
currentRecord, currentRecord,
loading: dataLoading, loading: dataLoading,
saving, saving,
@@ -50,7 +51,7 @@ const handleAiRecordCreated = (event: Event) => {
return return
} }
if (view.value === 'list') { if (view.value === 'list') {
fetchRecords() initializeListRecords()
} }
} }
@@ -82,6 +83,9 @@ const editConfig = computed(() => {
return buildEditViewConfig(objectDefinition.value) return buildEditViewConfig(objectDefinition.value)
}) })
const listPageSize = computed(() => listConfig.value?.pageSize ?? 25)
const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?? 500)
// Fetch object definition // Fetch object definition
const fetchObjectDefinition = async () => { const fetchObjectDefinition = async () => {
try { try {
@@ -160,6 +164,39 @@ const handleCancel = () => {
} }
} }
const loadListRecords = async (
page = 1,
options?: { append?: boolean; pageSize?: number }
) => {
const pageSize = options?.pageSize ?? listPageSize.value
const result = await fetchRecords({ page, pageSize }, { append: options?.append })
const resolvedTotal = result?.totalCount ?? totalCount.value ?? records.value.length
totalCount.value = resolvedTotal
return result
}
const initializeListRecords = async () => {
const firstResult = await loadListRecords(1)
const resolvedTotal = firstResult?.totalCount ?? totalCount.value ?? records.value.length
const shouldPrefetchAll =
resolvedTotal <= maxFrontendRecords.value && records.value.length < resolvedTotal
if (shouldPrefetchAll) {
await loadListRecords(1, { append: false, pageSize: maxFrontendRecords.value })
}
}
const handlePageChange = async (page: number, pageSize: number) => {
const loadedPages = Math.ceil(records.value.length / pageSize)
if (page > loadedPages && totalCount.value > records.value.length) {
await loadListRecords(page, { append: true, pageSize })
}
}
const handleLoadMore = async (page: number, pageSize: number) => {
await loadListRecords(page, { append: true, pageSize })
}
// 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'
@@ -174,7 +211,7 @@ watch(() => route.params, async (newParams, oldParams) => {
// Fetch records if navigating back to list // Fetch records if navigating back to list
if (!newParams.recordId && !newParams.view) { if (!newParams.recordId && !newParams.view) {
await fetchRecords() await initializeListRecords()
} }
}, { deep: true }) }, { deep: true })
@@ -183,7 +220,7 @@ onMounted(async () => {
await fetchObjectDefinition() await fetchObjectDefinition()
if (view.value === 'list') { if (view.value === 'list') {
await fetchRecords() await initializeListRecords()
} else if (recordId.value && recordId.value !== 'new') { } else if (recordId.value && recordId.value !== 'new') {
await fetchRecord(recordId.value) await fetchRecord(recordId.value)
} }
@@ -225,11 +262,14 @@ onMounted(async () => {
:config="listConfig" :config="listConfig"
:data="records" :data="records"
:loading="dataLoading" :loading="dataLoading"
:total-count="totalCount"
selectable selectable
@row-click="handleRowClick" @row-click="handleRowClick"
@create="handleCreate" @create="handleCreate"
@edit="handleEdit" @edit="handleEdit"
@delete="handleDelete" @delete="handleDelete"
@page-change="handlePageChange"
@load-more="handleLoadMore"
/> />
<!-- Detail View --> <!-- Detail View -->

View File

@@ -114,6 +114,7 @@ export interface ViewConfig {
export interface ListViewConfig extends ViewConfig { export interface ListViewConfig extends ViewConfig {
mode: ViewMode.LIST; mode: ViewMode.LIST;
pageSize?: number; pageSize?: number;
maxFrontendRecords?: number;
searchable?: boolean; searchable?: boolean;
filterable?: boolean; filterable?: boolean;
exportable?: boolean; exportable?: boolean;