Files

528 lines
16 KiB
Vue
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useApi } from '@/composables/useApi'
import { useFields, useViewState } from '@/composables/useFieldViews'
import { usePageLayouts } from '@/composables/usePageLayouts'
import ListView from '@/components/views/ListView.vue'
import DetailView from '@/components/views/DetailViewEnhanced.vue'
import EditView from '@/components/views/EditViewEnhanced.vue'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
const route = useRoute()
const router = useRouter()
const { api } = useApi()
const { buildListViewConfig, buildDetailViewConfig, buildEditViewConfig } = useFields()
const { getDefaultPageLayout } = usePageLayouts()
// Use breadcrumbs composable
const { setBreadcrumbs } = useBreadcrumbs()
// Get object API name from route (case-insensitive)
const objectApiName = computed(() => {
const name = route.params.objectName as string
// We'll look up the actual case-sensitive name from the backend
return name
})
const recordId = computed(() => route.params.recordId as string)
const view = computed(() => {
// If recordId is 'new', default to 'edit' view
if (route.params.recordId === 'new' && !route.params.view) {
return 'edit'
}
return (route.params.view as 'list' | 'detail' | 'edit') || 'list'
})
// State
const objectDefinition = ref<any>(null)
const listViewLayout = ref<any>(null)
const loading = ref(true)
const error = ref<string | null>(null)
// Use view state composable
const {
records,
totalCount,
currentRecord,
loading: dataLoading,
saving,
fetchRecords,
fetchRecord,
deleteRecord,
deleteRecords,
handleSave,
} = useViewState(`/runtime/objects/${objectApiName.value}/records`)
const handleAiRecordCreated = (event: Event) => {
const detail = (event as CustomEvent).detail || {}
if (
detail?.objectApiName &&
detail.objectApiName.toLowerCase() !== objectApiName.value.toLowerCase()
) {
return
}
if (view.value === 'list') {
initializeListRecords()
}
}
onMounted(() => {
window.addEventListener('ai-record-created', handleAiRecordCreated)
})
onBeforeUnmount(() => {
window.removeEventListener('ai-record-created', handleAiRecordCreated)
})
// Compute breadcrumbs based on the current route and object data
const updateBreadcrumbs = () => {
if (!objectDefinition.value) {
return
}
const crumbs: Array<{ name: string; path?: string; isLast?: boolean }> = []
// Add app breadcrumb if object belongs to an app
if (objectDefinition.value?.app) {
crumbs.push({
name: objectDefinition.value.app.label || objectDefinition.value.app.name,
path: undefined, // No path for app grouping
})
}
// Add object breadcrumb - always use plural
const objectLabel = objectDefinition.value?.pluralLabel || objectDefinition.value?.label || objectApiName.value
crumbs.push({
name: objectLabel,
path: `/${objectApiName.value.toLowerCase()}`,
})
// Add record name if viewing/editing a specific record
if (recordId.value && recordId.value !== 'new' && currentRecord.value) {
const nameField = objectDefinition.value?.nameField
let recordName = recordId.value // fallback to ID
// Try to get the display name from the nameField
if (nameField && currentRecord.value[nameField]) {
recordName = currentRecord.value[nameField]
}
crumbs.push({
name: recordName,
isLast: true,
})
} else if (recordId.value === 'new') {
crumbs.push({
name: 'New',
isLast: true,
})
}
setBreadcrumbs(crumbs)
}
// Watch for changes that affect breadcrumbs
watch([objectDefinition, currentRecord, recordId], () => {
updateBreadcrumbs()
}, { deep: true })
// View configs
const listConfig = computed(() => {
if (!objectDefinition.value) return null
// Pass the list view layout config to buildListViewConfig if available
const layoutConfig = listViewLayout.value?.layout_config || listViewLayout.value?.layoutConfig
return buildListViewConfig(objectDefinition.value, {
searchable: true,
exportable: true,
filterable: true,
}, layoutConfig)
})
const detailConfig = computed(() => {
if (!objectDefinition.value) return null
return buildDetailViewConfig(objectDefinition.value)
})
const editConfig = computed(() => {
if (!objectDefinition.value) return null
return buildEditViewConfig(objectDefinition.value)
})
const listPageSize = computed(() => listConfig.value?.pageSize ?? 25)
const maxFrontendRecords = computed(() => listConfig.value?.maxFrontendRecords ?? 500)
const searchQuery = ref('')
const searchSummary = ref('')
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 pendingDeleteCount = computed(() => pendingDeleteRows.value.length)
const deniedDeleteCount = computed(() => deleteSummary.value?.deniedIds.length ?? 0)
// Fetch object definition
const fetchObjectDefinition = async () => {
try {
loading.value = true
error.value = null
const response = await api.get(`/setup/objects/${objectApiName.value}`)
objectDefinition.value = response
// Fetch the default list view layout for this object
if (response?.id) {
try {
listViewLayout.value = await getDefaultPageLayout(response.id, 'list')
} catch (e) {
// No list view layout configured, will use default behavior
listViewLayout.value = null
}
}
} catch (e: any) {
error.value = e.message || 'Failed to load object definition'
console.error('Error fetching object definition:', e)
} finally {
loading.value = false
}
}
// Navigation handlers - use lowercase URLs
const handleRowClick = (row: any) => {
router.push(`/${objectApiName.value.toLowerCase()}/${row.id}/detail`)
}
const handleCreate = () => {
router.push(`/${objectApiName.value.toLowerCase()}/new`)
}
const handleEdit = (row?: any) => {
const id = row?.id || recordId.value
router.push(`/${objectApiName.value.toLowerCase()}/${id}/edit`)
}
const handleBack = () => {
// Navigate to list view explicitly
router.push(`/${objectApiName.value.toLowerCase()}/`)
}
const handleNavigate = (relatedObjectApiName: string, relatedRecordId: string) => {
router.push(`/${relatedObjectApiName.toLowerCase()}/${relatedRecordId}/detail`)
}
const handleCreateRelated = (relatedObjectApiName: string, _parentId: string) => {
router.push(`/${relatedObjectApiName.toLowerCase()}/new`)
}
const handleDelete = async (rows: any[]) => {
pendingDeleteRows.value = rows
deleteSummary.value = null
deleteDialogOpen.value = true
}
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') {
await router.push(`/${objectApiName.value.toLowerCase()}/`)
}
}
} catch (e: any) {
error.value = e.message || 'Failed to delete records'
} finally {
deleteSubmitting.value = false
}
}
const handleSaveRecord = async (data: any) => {
try {
const savedRecord = await handleSave(data)
if (savedRecord?.id) {
router.push(`/${objectApiName.value.toLowerCase()}/${savedRecord.id}/detail`)
} else {
// Fallback to list if no ID available
router.push(`/${objectApiName.value.toLowerCase()}/`)
}
} catch (e: any) {
error.value = e.message || 'Failed to save record'
}
}
const handleCancel = () => {
if (recordId.value && recordId.value !== 'new') {
router.push(`/${objectApiName.value.toLowerCase()}/${recordId.value}/detail`)
} else {
router.push(`/${objectApiName.value.toLowerCase()}/`)
}
}
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 searchListRecords = async (
page = 1,
options?: { append?: boolean; pageSize?: number }
) => {
if (!isSearchActive.value) {
return initializeListRecords()
}
searchLoading.value = true
try {
const pageSize = options?.pageSize ?? listPageSize.value
const response = await api.post('/ai/search', {
objectApiName: objectApiName.value,
query: searchQuery.value.trim(),
page,
pageSize,
})
const data = response?.data ?? []
const total = response?.totalCount ?? data.length
records.value = options?.append ? [...records.value, ...data] : data
totalCount.value = total
searchSummary.value = response?.explanation || ''
return response
} catch (e: any) {
error.value = e.message || 'Failed to search records'
return null
} finally {
searchLoading.value = false
}
}
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) => {
if (isSearchActive.value) {
await searchListRecords(page, { append: page > 1, pageSize })
return
}
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) => {
if (isSearchActive.value) {
await searchListRecords(page, { append: true, pageSize })
return
}
await loadListRecords(page, { append: true, pageSize })
}
const handleSearch = async (query: string) => {
const trimmed = query.trim()
searchQuery.value = trimmed
if (!trimmed) {
searchSummary.value = ''
await initializeListRecords()
return
}
await searchListRecords(1, { append: false, pageSize: listPageSize.value })
}
// Watch for route changes
watch(() => route.params, async (newParams, oldParams) => {
// Reset current record when navigating to 'new'
if (newParams.recordId === 'new') {
currentRecord.value = null
}
// Fetch record if navigating to existing record
if (newParams.recordId && newParams.recordId !== 'new' && newParams.recordId !== oldParams.recordId) {
await fetchRecord(newParams.recordId as string)
}
// Fetch records if navigating back to list
if (!newParams.recordId && !newParams.view) {
await initializeListRecords()
}
}, { deep: true })
// Initialize
onMounted(async () => {
await fetchObjectDefinition()
if (view.value === 'list') {
await initializeListRecords()
} else if (recordId.value && recordId.value !== 'new') {
await fetchRecord(recordId.value)
}
// Update breadcrumbs after data is loaded
updateBreadcrumbs()
})
</script>
<template>
<NuxtLayout name="default">
<div class="object-view-container">
<!-- Page Header -->
<div v-if="!loading && !error && view === 'list'" class="mb-6">
<h1 class="text-3xl font-bold">{{ objectDefinition?.label || objectApiName }}</h1>
<p v-if="objectDefinition?.description" class="text-muted-foreground mt-2">
{{ objectDefinition.description }}
</p>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center min-h-screen">
<div class="text-center space-y-4">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p class="text-muted-foreground">Loading {{ objectApiName }}...</p>
</div>
</div>
<!-- Error State -->
<div v-else-if="error" class="flex items-center justify-center min-h-screen">
<div class="text-center space-y-4 max-w-md">
<div class="text-destructive text-5xl"></div>
<h2 class="text-2xl font-bold">Error</h2>
<p class="text-muted-foreground">{{ error }}</p>
<Button @click="router.back()">Go Back</Button>
</div>
</div>
<!-- List View -->
<ListView
v-else-if="view === 'list' && listConfig"
:config="listConfig"
:data="records"
:loading="dataLoading || searchLoading"
:total-count="totalCount"
:search-summary="searchSummary"
:base-url="`/runtime/objects`"
selectable
@row-click="handleRowClick"
@create="handleCreate"
@edit="handleEdit"
@delete="handleDelete"
@search="handleSearch"
@page-change="handlePageChange"
@load-more="handleLoadMore"
/>
<!-- Detail View -->
<DetailView
v-else-if="view === 'detail' && detailConfig && currentRecord"
:config="detailConfig"
:data="currentRecord"
:loading="dataLoading"
:object-id="objectDefinition?.id"
:base-url="`/runtime/objects`"
@edit="handleEdit"
@delete="() => handleDelete([currentRecord])"
@back="handleBack"
@navigate="handleNavigate"
@create-related="handleCreateRelated"
/>
<!-- Edit View -->
<EditView
v-else-if="(view === 'edit' || recordId === 'new') && editConfig"
:config="editConfig"
:data="currentRecord || {}"
:loading="dataLoading"
:saving="saving"
:object-id="objectDefinition?.id"
:base-url="`/runtime/objects`"
@save="handleSaveRecord"
@cancel="handleCancel"
@back="handleBack"
/>
</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>
</template>
<style scoped>
.object-view-container {
padding: 2rem;
}
</style>