WIP - Mass delete working

This commit is contained in:
Francisco Gaona
2026-01-14 02:28:52 +01:00
parent f094fee192
commit 842376ba15
6 changed files with 197 additions and 32 deletions

View File

@@ -694,8 +694,6 @@ export class AiAssistantService {
type: field.type, type: field.type,
})); }));
console.log('fields:',fields);
const formatInstructions = parser.getFormatInstructions(); const formatInstructions = parser.getFormatInstructions();
const today = new Date().toISOString(); const today = new Date().toISOString();

View File

@@ -1284,10 +1284,23 @@ export class ObjectService {
if (missingIds.length > 0) { if (missingIds.length > 0) {
throw new NotFoundException(`Records not found: ${missingIds.join(', ')}`); throw new NotFoundException(`Records not found: ${missingIds.join(', ')}`);
} }
// Check if user can delete each record const deletableIds: string[] = [];
const deniedIds: string[] = [];
for (const record of records) { for (const record of records) {
await this.authService.assertCanPerformAction('delete', objectDefModel, record, user, knex); const canDelete = await this.authService.canPerformAction(
'delete',
objectDefModel,
record,
user,
knex,
);
if (canDelete) {
deletableIds.push(record.id);
} else {
deniedIds.push(record.id);
}
} }
// Ensure model is registered // Ensure model is registered
@@ -1295,14 +1308,23 @@ export class ObjectService {
// Use Objection model // Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
await boundModel.query().whereIn('id', recordIds).delete(); if (deletableIds.length > 0) {
await boundModel.query().whereIn('id', deletableIds).delete();
}
// Remove from search index // Remove from search index
await Promise.all( await Promise.all(
recordIds.map((id) => this.removeIndexedRecord(resolvedTenantId, objectApiName, id)), deletableIds.map((id) =>
this.removeIndexedRecord(resolvedTenantId, objectApiName, id),
),
); );
return { success: true, deleted: recordIds.length }; return {
success: true,
deleted: deletableIds.length,
deletedIds: deletableIds,
deniedIds,
};
} }
private async indexRecord( private async indexRecord(

View File

@@ -18,7 +18,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
<CheckboxRoot <CheckboxRoot
v-bind="forwarded" v-bind="forwarded"
:class=" :class="
cn('grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground', cn('grid place-content-center peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
props.class)" props.class)"
> >
<CheckboxIndicator class="grid place-content-center text-current"> <CheckboxIndicator class="grid place-content-center text-current">

View File

@@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' 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 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 } 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'
@@ -49,11 +50,13 @@ const emit = defineEmits<{
}>() }>()
// State // State
const selectedRows = ref<Set<string>>(new Set()) const normalizeId = (id: any) => String(id)
const selectedRowIds = ref<string[]>([])
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) const currentPage = ref(1)
const bulkAction = ref('delete')
// Computed // Computed
const visibleFields = computed(() => const visibleFields = computed(() =>
@@ -94,27 +97,39 @@ const showLoadMore = computed(() => (
)) ))
const allSelected = computed({ const allSelected = computed({
get: () => props.data.length > 0 && selectedRows.value.size === props.data.length, get: () => props.data.length > 0 && selectedRowIds.value.length === props.data.length,
set: (val: boolean) => { set: (val: boolean) => {
if (val) { if (val) {
selectedRows.value = new Set(props.data.map(row => row.id)) selectedRowIds.value = props.data.map(row => normalizeId(row.id))
} else { } else {
selectedRows.value.clear() selectedRowIds.value = []
} }
emit('row-select', getSelectedRows()) emit('row-select', getSelectedRows())
}, },
}) })
const getSelectedRows = () => { const getSelectedRows = () => {
return props.data.filter(row => selectedRows.value.has(row.id)) const idSet = new Set(selectedRowIds.value)
return props.data.filter(row => idSet.has(normalizeId(row.id)))
} }
const toggleRowSelection = (rowId: string) => { const toggleRowSelection = (rowId: string) => {
if (selectedRows.value.has(rowId)) { const normalizedId = normalizeId(rowId)
selectedRows.value.delete(rowId) const nextSelection = new Set(selectedRowIds.value)
nextSelection.has(normalizedId) ? nextSelection.delete(normalizedId) : nextSelection.add(normalizedId)
selectedRowIds.value = Array.from(nextSelection)
emit('row-select', getSelectedRows())
}
const setRowSelection = (rowId: string, checked: boolean) => {
const normalizedId = normalizeId(rowId)
const nextSelection = new Set(selectedRowIds.value)
if (checked) {
nextSelection.add(normalizedId)
} else { } else {
selectedRows.value.add(rowId) nextSelection.delete(normalizedId)
} }
selectedRowIds.value = Array.from(nextSelection)
emit('row-select', getSelectedRows()) emit('row-select', getSelectedRows())
} }
@@ -136,6 +151,14 @@ const handleAction = (actionId: string) => {
emit('action', actionId, getSelectedRows()) emit('action', actionId, getSelectedRows())
} }
const handleBulkAction = () => {
if (bulkAction.value === 'delete') {
emit('delete', getSelectedRows())
return
}
emit('action', bulkAction.value, getSelectedRows())
}
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) {
@@ -157,6 +180,19 @@ watch(
} }
} }
) )
watch(
() => props.data,
(rows) => {
const rowIds = new Set(rows.map(row => normalizeId(row.id)))
const nextSelection = selectedRowIds.value.filter(id => rowIds.has(id))
if (nextSelection.length !== selectedRowIds.value.length) {
selectedRowIds.value = nextSelection
emit('row-select', getSelectedRows())
}
},
{ deep: true }
)
</script> </script>
<template> <template>
@@ -181,14 +217,24 @@ watch(
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<!-- Bulk Actions --> <!-- Bulk Actions -->
<template v-if="selectedRows.size > 0"> <template v-if="selectedRowIds.length > 0">
<Badge variant="secondary" class="px-3 py-1"> <Badge variant="secondary" class="px-3 py-1">
{{ selectedRows.size }} selected {{ selectedRowIds.length }} selected
</Badge> </Badge>
<Button variant="outline" size="sm" @click="emit('delete', getSelectedRows())"> <div class="flex items-center gap-2">
<Trash2 class="h-4 w-4 mr-2" /> <Select v-model="bulkAction" @update:model-value="(value) => bulkAction = value">
Delete <SelectTrigger class="h-8 w-[180px]">
</Button> <SelectValue placeholder="Select action" />
</SelectTrigger>
<SelectContent>
<SelectItem value="delete">Delete selected</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm" @click="handleBulkAction">
<Trash2 class="h-4 w-4 mr-2" />
Run
</Button>
</div>
</template> </template>
<!-- Custom Actions --> <!-- Custom Actions -->
@@ -222,7 +268,10 @@ watch(
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead v-if="selectable" class="w-12"> <TableHead v-if="selectable" class="w-12">
<Checkbox v-model:checked="allSelected" /> <Checkbox
:model-value="allSelected"
@update:model-value="(value: boolean) => (allSelected = value)"
/>
</TableHead> </TableHead>
<TableHead <TableHead
v-for="field in visibleFields" v-for="field in visibleFields"
@@ -263,8 +312,8 @@ watch(
> >
<TableCell v-if="selectable" @click.stop> <TableCell v-if="selectable" @click.stop>
<Checkbox <Checkbox
:checked="selectedRows.has(row.id)" :model-value="selectedRowIds.includes(normalizeId(row.id))"
@update:checked="toggleRowSelection(row.id)" @update:model-value="(checked: boolean) => setRowSelection(normalizeId(row.id), checked)"
/> />
</TableCell> </TableCell>
<TableCell v-for="field in visibleFields" :key="field.id"> <TableCell v-for="field in visibleFields" :key="field.id">

View File

@@ -330,9 +330,25 @@ export const useViewState = <T extends { id?: string }>(
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const useBulkEndpoint = apiEndpoint.includes('/runtime/objects/')
if (useBulkEndpoint) {
const response = await api.post(`${apiEndpoint}/bulk-delete`, { ids })
const deletedIds = Array.isArray(response?.deletedIds) ? response.deletedIds : ids
records.value = records.value.filter(r => !deletedIds.includes(r.id!))
totalCount.value = Math.max(0, totalCount.value - deletedIds.length)
return {
deletedIds,
deniedIds: Array.isArray(response?.deniedIds) ? response.deniedIds : [],
}
}
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) totalCount.value = Math.max(0, totalCount.value - ids.length)
return {
deletedIds: ids,
deniedIds: [],
}
} 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)

View File

@@ -6,6 +6,14 @@ import { useFields, useViewState } from '@/composables/useFieldViews'
import ListView from '@/components/views/ListView.vue' import ListView from '@/components/views/ListView.vue'
import DetailView from '@/components/views/DetailViewEnhanced.vue' import DetailView from '@/components/views/DetailViewEnhanced.vue'
import EditView from '@/components/views/EditViewEnhanced.vue' import EditView from '@/components/views/EditViewEnhanced.vue'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -148,8 +156,14 @@ const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?
const searchQuery = ref('') const searchQuery = ref('')
const searchSummary = ref('') const searchSummary = ref('')
const searchLoading = ref(false) const searchLoading = ref(false)
const deleteDialogOpen = ref(false)
const deleteSubmitting = ref(false)
const pendingDeleteRows = ref<any[]>([])
const deleteSummary = ref<{ deletedIds: string[]; deniedIds: string[] } | null>(null)
const isSearchActive = computed(() => searchQuery.value.trim().length > 0) const isSearchActive = computed(() => searchQuery.value.trim().length > 0)
const pendingDeleteCount = computed(() => pendingDeleteRows.value.length)
const deniedDeleteCount = computed(() => deleteSummary.value?.deniedIds.length ?? 0)
// Fetch object definition // Fetch object definition
const fetchObjectDefinition = async () => { const fetchObjectDefinition = async () => {
@@ -194,16 +208,42 @@ const handleCreateRelated = (relatedObjectApiName: string, _parentId: string) =>
} }
const handleDelete = async (rows: any[]) => { const handleDelete = async (rows: any[]) => {
if (confirm(`Delete ${rows.length} record(s)? This action cannot be undone.`)) { pendingDeleteRows.value = rows
try { deleteSummary.value = null
const ids = rows.map(r => r.id) deleteDialogOpen.value = true
await deleteRecords(ids) }
const resetDeleteDialog = () => {
deleteDialogOpen.value = false
deleteSubmitting.value = false
pendingDeleteRows.value = []
deleteSummary.value = null
}
const confirmDelete = async () => {
if (pendingDeleteRows.value.length === 0) {
resetDeleteDialog()
return
}
deleteSubmitting.value = true
try {
const ids = pendingDeleteRows.value.map(r => r.id)
const result = await deleteRecords(ids)
const deletedIds = result?.deletedIds ?? []
const deniedIds = result?.deniedIds ?? []
deleteSummary.value = { deletedIds, deniedIds }
if (deniedIds.length === 0) {
resetDeleteDialog()
if (view.value !== 'list') { if (view.value !== 'list') {
await router.push(`/${objectApiName.value.toLowerCase()}/`) await router.push(`/${objectApiName.value.toLowerCase()}/`)
} }
} catch (e: any) {
error.value = e.message || 'Failed to delete records'
} }
} catch (e: any) {
error.value = e.message || 'Failed to delete records'
} finally {
deleteSubmitting.value = false
} }
} }
@@ -422,6 +462,46 @@ onMounted(async () => {
@back="handleBack" @back="handleBack"
/> />
</div> </div>
<Dialog v-model:open="deleteDialogOpen">
<DialogContent class="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Delete records</DialogTitle>
<DialogDescription>
This action cannot be undone.
</DialogDescription>
</DialogHeader>
<div class="space-y-2 text-sm">
<p>
You are about to delete {{ pendingDeleteCount }} record<span v-if="pendingDeleteCount !== 1">s</span>.
</p>
<p v-if="deleteSummary" class="text-muted-foreground">
Deleted {{ deleteSummary.deletedIds.length }} record<span v-if="deleteSummary.deletedIds.length !== 1">s</span>.
</p>
<p v-if="deniedDeleteCount > 0" class="text-destructive">
{{ deniedDeleteCount }} record<span v-if="deniedDeleteCount !== 1">s</span> could not be deleted due to missing permissions.
</p>
<p v-if="!deleteSummary" class="text-muted-foreground">
Records you do not have permission to delete will be skipped.
</p>
</div>
<DialogFooter>
<Button variant="outline" @click="resetDeleteDialog" :disabled="deleteSubmitting">
{{ deleteSummary ? 'Close' : 'Cancel' }}
</Button>
<Button
v-if="!deleteSummary"
variant="destructive"
@click="confirmDelete"
:disabled="deleteSubmitting"
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</NuxtLayout> </NuxtLayout>
</template> </template>