WIP - Mass delete working
This commit is contained in:
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user