diff --git a/backend/src/object/object.service.ts b/backend/src/object/object.service.ts index ab896e9..bd08e60 100644 --- a/backend/src/object/object.service.ts +++ b/backend/src/object/object.service.ts @@ -576,11 +576,48 @@ export class ObjectService { } } - // Apply additional filters - if (filters) { - query = query.where(filters); + // Extract pagination and sorting parameters from query string + const { + 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('*'); // 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( @@ -952,6 +994,73 @@ export class ObjectService { 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( tenantId: string, objectApiName: string, diff --git a/backend/src/object/runtime-object.controller.ts b/backend/src/object/runtime-object.controller.ts index 6a55e05..139c759 100644 --- a/backend/src/object/runtime-object.controller.ts +++ b/backend/src/object/runtime-object.controller.ts @@ -95,4 +95,20 @@ export class RuntimeObjectController { 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, + ); + } } diff --git a/frontend/components/fields/LookupField.vue b/frontend/components/fields/LookupField.vue index 9138136..f3a2364 100644 --- a/frontend/components/fields/LookupField.vue +++ b/frontend/components/fields/LookupField.vue @@ -78,7 +78,9 @@ const fetchRecords = async () => { try { const endpoint = `${props.baseUrl}/${relationObject.value}/records` 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 (props.modelValue) { diff --git a/frontend/components/views/ListView.vue b/frontend/components/views/ListView.vue index 5284e89..a40b538 100644 --- a/frontend/components/views/ListView.vue +++ b/frontend/components/views/ListView.vue @@ -1,5 +1,5 @@ @@ -192,7 +251,7 @@ const handleAction = (actionId: string) => { { - + + + Showing {{ pageStart }}-{{ pageEnd }} of {{ totalRecords }} records + + (loaded {{ data.length }}) + + + + + Previous + + Page {{ currentPage }} of {{ totalPages }} + + Next + + + Load more + + + diff --git a/frontend/composables/useFieldViews.ts b/frontend/composables/useFieldViews.ts index 696f5a4..27910d8 100644 --- a/frontend/composables/useFieldViews.ts +++ b/frontend/composables/useFieldViews.ts @@ -78,7 +78,8 @@ export const useFields = () => { objectApiName: objectDef.apiName, mode: 'list' as ViewMode, fields, - pageSize: 25, + pageSize: 10, + maxFrontendRecords: 500, searchable: true, filterable: true, exportable: true, @@ -183,6 +184,7 @@ export const useViewState = ( apiEndpoint: string ) => { const records = ref([]) + const totalCount = ref(0) const currentRecord = ref(null) const currentView = ref<'list' | 'detail' | 'edit'>('list') const loading = ref(false) @@ -191,13 +193,51 @@ export const useViewState = ( const { api } = useApi() - const fetchRecords = async (params?: Record) => { + 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, options?: { append?: boolean }) => { loading.value = true error.value = null try { const response = await api.get(apiEndpoint, { params }) - // Handle response - data might be directly in response or in response.data - records.value = response.data || response || [] + const normalized = normalizeListResponse(response) + totalCount.value = normalized.totalCount ?? normalized.data.length ?? 0 + records.value = options?.append + ? [...records.value, ...normalized.data] + : normalized.data + return normalized } catch (e: any) { error.value = e.message console.error('Failed to fetch records:', e) @@ -230,6 +270,7 @@ export const useViewState = ( // Handle response - it might be the data directly or wrapped in { data: ... } const recordData = response.data || response records.value.push(recordData) + totalCount.value += 1 currentRecord.value = recordData return recordData } catch (e: any) { @@ -272,6 +313,7 @@ export const useViewState = ( try { await api.delete(`${apiEndpoint}/${id}`) records.value = records.value.filter(r => r.id !== id) + totalCount.value = Math.max(0, totalCount.value - 1) if (currentRecord.value?.id === id) { currentRecord.value = null } @@ -290,6 +332,7 @@ export const useViewState = ( try { await Promise.all(ids.map(id => api.delete(`${apiEndpoint}/${id}`))) records.value = records.value.filter(r => !ids.includes(r.id!)) + totalCount.value = Math.max(0, totalCount.value - ids.length) } catch (e: any) { error.value = e.message console.error('Failed to delete records:', e) @@ -327,6 +370,7 @@ export const useViewState = ( return { // State records, + totalCount, currentRecord, currentView, loading, diff --git a/frontend/pages/[objectName]/[[recordId]]/[[view]].vue b/frontend/pages/[objectName]/[[recordId]]/[[view]].vue index 7049996..fa05ed6 100644 --- a/frontend/pages/[objectName]/[[recordId]]/[[view]].vue +++ b/frontend/pages/[objectName]/[[recordId]]/[[view]].vue @@ -1,5 +1,5 @@