Files
neo/frontend/components/views/ListView.vue

379 lines
12 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
import { ListViewConfig, ViewMode, FieldType } from '@/types/field-types'
import { ChevronDown, ChevronUp, Search, Plus, Download, Trash2, Edit } from 'lucide-vue-next'
interface Props {
config: ListViewConfig
data?: any[]
loading?: boolean
selectable?: boolean
baseUrl?: string
totalCount?: number
searchSummary?: string
}
const props = withDefaults(defineProps<Props>(), {
data: () => [],
loading: false,
selectable: false,
baseUrl: '/runtime/objects',
searchSummary: '',
})
const emit = defineEmits<{
'row-click': [row: any]
'row-select': [rows: any[]]
'create': []
'edit': [row: any]
'delete': [rows: any[]]
'action': [actionId: string, rows: any[]]
'sort': [field: string, direction: 'asc' | 'desc']
'search': [query: string]
'refresh': []
'page-change': [page: number, pageSize: number]
'load-more': [page: number, pageSize: number]
}>()
// State
const normalizeId = (id: any) => String(id)
const selectedRowIds = ref<string[]>([])
const searchQuery = ref('')
const sortField = ref<string>('')
const sortDirection = ref<'asc' | 'desc'>('asc')
const currentPage = ref(1)
const bulkAction = ref('delete')
// Computed
const visibleFields = computed(() =>
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({
get: () => props.data.length > 0 && selectedRowIds.value.length === props.data.length,
set: (val: boolean) => {
if (val) {
selectedRowIds.value = props.data.map(row => normalizeId(row.id))
} else {
selectedRowIds.value = []
}
emit('row-select', getSelectedRows())
},
})
const getSelectedRows = () => {
const idSet = new Set(selectedRowIds.value)
return props.data.filter(row => idSet.has(normalizeId(row.id)))
}
const toggleRowSelection = (rowId: string) => {
const normalizedId = normalizeId(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 {
nextSelection.delete(normalizedId)
}
selectedRowIds.value = Array.from(nextSelection)
emit('row-select', getSelectedRows())
}
const handleSort = (field: string) => {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'asc'
}
emit('sort', field, sortDirection.value)
}
const handleSearch = () => {
emit('search', searchQuery.value)
}
const handleAction = (actionId: string) => {
emit('action', actionId, getSelectedRows())
}
const handleBulkAction = () => {
if (bulkAction.value === 'delete') {
emit('delete', getSelectedRows())
return
}
emit('action', bulkAction.value, 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
}
}
)
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>
<template>
<div class="list-view space-y-4">
<!-- Toolbar -->
<div class="flex items-center justify-between gap-4">
<!-- Search -->
<div v-if="config.searchable !== false" class="flex-1 max-w-sm">
<div class="relative">
<Search class="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
v-model="searchQuery"
placeholder="Search..."
class="pl-8"
@keyup.enter="handleSearch"
/>
</div>
<p v-if="searchSummary" class="mt-2 text-xs text-muted-foreground">
{{ searchSummary }}
</p>
</div>
<div class="flex items-center gap-2">
<!-- Bulk Actions -->
<template v-if="selectedRowIds.length > 0">
<Badge variant="secondary" class="px-3 py-1">
{{ selectedRowIds.length }} selected
</Badge>
<div class="flex items-center gap-2">
<Select v-model="bulkAction" @update:model-value="(value) => bulkAction = value">
<SelectTrigger class="h-8 w-[180px]">
<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>
<!-- Custom Actions -->
<Button
v-for="action in config.actions"
:key="action.id"
:variant="action.variant || 'outline'"
size="sm"
@click="handleAction(action.id)"
>
{{ action.label }}
</Button>
<!-- Export -->
<Button v-if="config.exportable" variant="outline" size="sm">
<Download class="h-4 w-4 mr-2" />
Export
</Button>
<!-- Create -->
<Button size="sm" @click="emit('create')">
<Plus class="h-4 w-4 mr-2" />
New
</Button>
</div>
</div>
<!-- Table -->
<div class="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead v-if="selectable" class="w-12">
<Checkbox
:model-value="allSelected"
@update:model-value="(value: boolean) => (allSelected = value)"
/>
</TableHead>
<TableHead
v-for="field in visibleFields"
:key="field.id"
:class="{ 'cursor-pointer hover:bg-muted/50': field.sortable !== false }"
@click="field.sortable !== false && handleSort(field.apiName)"
>
<div class="flex items-center gap-2">
{{ field.label }}
<template v-if="field.sortable !== false && sortField === field.apiName">
<ChevronUp v-if="sortDirection === 'asc'" class="h-4 w-4" />
<ChevronDown v-else class="h-4 w-4" />
</template>
</div>
</TableHead>
<TableHead class="w-20">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="loading">
<TableCell :colspan="visibleFields.length + (selectable ? 2 : 1)" class="text-center py-8">
<div class="flex items-center justify-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
</TableCell>
</TableRow>
<TableRow v-else-if="data.length === 0">
<TableCell :colspan="visibleFields.length + (selectable ? 2 : 1)" class="text-center py-8 text-muted-foreground">
No records found
</TableCell>
</TableRow>
<TableRow
v-else
v-for="row in paginatedData"
:key="row.id"
class="cursor-pointer hover:bg-muted/50"
@click="emit('row-click', row)"
>
<TableCell v-if="selectable" @click.stop>
<Checkbox
:model-value="selectedRowIds.includes(normalizeId(row.id))"
@update:model-value="(checked: boolean) => setRowSelection(normalizeId(row.id), checked)"
/>
</TableCell>
<TableCell v-for="field in visibleFields" :key="field.id">
<FieldRenderer
:field="field"
:model-value="row[field.apiName]"
:record-data="row"
:mode="ViewMode.LIST"
:base-url="baseUrl"
/>
</TableCell>
<TableCell @click.stop>
<div class="flex items-center gap-1">
<Button variant="ghost" size="sm" @click="emit('edit', row)">
<Edit class="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" @click="emit('delete', [row])">
<Trash2 class="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<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>
</template>
<style scoped>
.list-view {
width: 100%;
}
.list-view :deep(.border) {
background-color: hsl(var(--card));
}
.list-view :deep(input) {
background-color: hsl(var(--background));
}
</style>