WIP - related lists and look up field

This commit is contained in:
Francisco Gaona
2025-12-23 23:59:04 +01:00
parent 0275b96014
commit fc1bec4de7
11 changed files with 774 additions and 12 deletions

View File

@@ -0,0 +1,186 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Plus, ExternalLink } from 'lucide-vue-next'
import type { FieldConfig } from '@/types/field-types'
interface RelatedListConfig {
title: string
relationName: string // e.g., 'domains', 'users'
objectApiName: string // e.g., 'domains', 'users'
fields: FieldConfig[] // Fields to display in the list
canCreate?: boolean
createRoute?: string // Route to create new related record
}
interface Props {
config: RelatedListConfig
parentId: string
relatedRecords?: any[] // Can be passed in if already fetched
baseUrl?: string // Base API URL, defaults to '/api/central'
}
const props = withDefaults(defineProps<Props>(), {
baseUrl: '/api/central',
relatedRecords: undefined,
})
const emit = defineEmits<{
'navigate': [objectApiName: string, recordId: string]
'create': [objectApiName: string, parentId: string]
}>()
const { $api } = useNuxtApp() as unknown as { $api: Function }
const records = ref<any[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
// Use provided records or fetch them
const displayRecords = computed(() => {
return props.relatedRecords || records.value
})
const fetchRelatedRecords = async () => {
if (props.relatedRecords) {
// Records already provided, no need to fetch
return
}
loading.value = true
error.value = null
try {
const response = await $api(`${props.baseUrl}/${props.config.objectApiName}`, {
params: {
parentId: props.parentId,
},
})
records.value = response || []
} catch (err: any) {
console.error('Error fetching related records:', err)
error.value = err.message || 'Failed to fetch related records'
} finally {
loading.value = false
}
}
const handleCreateNew = () => {
emit('create', props.config.objectApiName, props.parentId)
}
const handleViewRecord = (recordId: string) => {
emit('navigate', props.config.objectApiName, recordId)
}
const formatValue = (value: any, field: FieldConfig): string => {
if (value === null || value === undefined) return '-'
// Handle different field types
if (field.type === 'date') {
return new Date(value).toLocaleDateString()
}
if (field.type === 'datetime') {
return new Date(value).toLocaleString()
}
if (field.type === 'boolean') {
return value ? 'Yes' : 'No'
}
if (field.type === 'select' && field.options) {
const option = field.options.find(opt => opt.value === value)
return option?.label || value
}
return String(value)
}
onMounted(() => {
fetchRelatedRecords()
})
</script>
<template>
<Card class="related-list">
<CardHeader>
<div class="flex items-center justify-between">
<div>
<CardTitle>{{ config.title }}</CardTitle>
<CardDescription v-if="displayRecords.length > 0">
{{ displayRecords.length }} {{ displayRecords.length === 1 ? 'record' : 'records' }}
</CardDescription>
</div>
<Button
v-if="config.canCreate !== false"
size="sm"
@click="handleCreateNew"
>
<Plus class="h-4 w-4 mr-2" />
New
</Button>
</div>
</CardHeader>
<CardContent>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-sm text-destructive py-4">
{{ error }}
</div>
<!-- Empty State -->
<div v-else-if="displayRecords.length === 0" class="text-center py-8 text-muted-foreground">
<p class="text-sm">No {{ config.title.toLowerCase() }} yet</p>
<Button
v-if="config.canCreate !== false"
variant="outline"
size="sm"
class="mt-4"
@click="handleCreateNew"
>
<Plus class="h-4 w-4 mr-2" />
Create First {{ config.title.slice(0, -1) }}
</Button>
</div>
<!-- Records Table -->
<div v-else class="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead v-for="field in config.fields" :key="field.id">
{{ field.label }}
</TableHead>
<TableHead class="w-[80px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="record in displayRecords" :key="record.id">
<TableCell v-for="field in config.fields" :key="field.id">
{{ formatValue(record[field.apiName], field) }}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
@click="handleViewRecord(record.id)"
>
<ExternalLink class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</template>
<style scoped>
.related-list {
margin-top: 1.5rem;
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch, onMounted } from 'vue'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@@ -9,19 +9,31 @@ import { DatePicker } from '@/components/ui/date-picker'
import { Badge } from '@/components/ui/badge'
import { FieldConfig, FieldType, ViewMode } from '@/types/field-types'
import { Label } from '@/components/ui/label'
import LookupField from '@/components/fields/LookupField.vue'
interface Props {
field: FieldConfig
modelValue: any
mode: ViewMode
readonly?: boolean
baseUrl?: string // Base URL for API calls
recordData?: any // Full record data to access related objects
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
baseUrl: '/api/central',
})
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const { $api } = useNuxtApp() as unknown as { $api: Function }
// For relationship fields, store the related record for display
const relatedRecord = ref<any | null>(null)
const loadingRelated = ref(false)
const value = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
@@ -32,10 +44,88 @@ const isEditMode = computed(() => props.mode === ViewMode.EDIT)
const isListMode = computed(() => props.mode === ViewMode.LIST)
const isDetailMode = computed(() => props.mode === ViewMode.DETAIL)
// Check if field is a relationship field
const isRelationshipField = computed(() => {
return [FieldType.BELONGS_TO].includes(props.field.type)
})
// Get relation object name (e.g., 'tenants' -> singular 'tenant')
const getRelationPropertyName = () => {
const relationObject = props.field.relationObject || props.field.apiName.replace('Id', '')
// Convert plural to singular for property name (e.g., 'tenants' -> 'tenant')
return relationObject.endsWith('s') ? relationObject.slice(0, -1) : relationObject
}
// Fetch related record for display
const fetchRelatedRecord = async () => {
if (!isRelationshipField.value || !props.modelValue) return
const relationObject = props.field.relationObject || props.field.apiName.replace('Id', '')
const displayField = props.field.relationDisplayField || 'name'
loadingRelated.value = true
try {
const record = await $api(`${props.baseUrl}/${relationObject}/${props.modelValue}`)
relatedRecord.value = record
} catch (err) {
console.error('Error fetching related record:', err)
relatedRecord.value = null
} finally {
loadingRelated.value = false
}
}
// Display value for relationship fields
const relationshipDisplayValue = computed(() => {
if (!isRelationshipField.value) return props.modelValue || '-'
// First, check if the parent record data includes the related object
// This happens when backend uses .withGraphFetched()
if (props.recordData) {
const relationPropertyName = getRelationPropertyName()
const relatedObject = props.recordData[relationPropertyName]
if (relatedObject && typeof relatedObject === 'object') {
const displayField = props.field.relationDisplayField || 'name'
return relatedObject[displayField] || relatedObject.id || props.modelValue
}
}
// Otherwise use the fetched related record
if (relatedRecord.value) {
const displayField = props.field.relationDisplayField || 'name'
return relatedRecord.value[displayField] || relatedRecord.value.id
}
// Show loading state
if (loadingRelated.value) {
return 'Loading...'
}
// Fallback to ID
return props.modelValue || '-'
})
// Watch for changes in modelValue for relationship fields
watch(() => props.modelValue, () => {
if (isRelationshipField.value && (isDetailMode.value || isListMode.value)) {
fetchRelatedRecord()
}
})
// Load related record on mount if needed
onMounted(() => {
if (isRelationshipField.value && props.modelValue && (isDetailMode.value || isListMode.value)) {
fetchRelatedRecord()
}
})
const formatValue = (val: any): string => {
if (val === null || val === undefined) return '-'
switch (props.field.type) {
case FieldType.BELONGS_TO:
return relationshipDisplayValue.value
case FieldType.DATE:
return val instanceof Date ? val.toLocaleDateString() : new Date(val).toLocaleDateString()
case FieldType.DATETIME:
@@ -113,9 +203,17 @@ const formatValue = (val: any): string => {
<!-- Edit View - Input components -->
<div v-else-if="isEditMode && !isReadOnly">
<!-- Relationship Field - Lookup -->
<LookupField
v-if="field.type === FieldType.BELONGS_TO"
:field="field"
v-model="value"
:base-url="baseUrl"
/>
<!-- Text Input -->
<Input
v-if="[FieldType.TEXT, FieldType.EMAIL, FieldType.URL, FieldType.PASSWORD].includes(field.type)"
v-else-if="[FieldType.TEXT, FieldType.EMAIL, FieldType.URL, FieldType.PASSWORD].includes(field.type)"
:id="field.id"
v-model="value"
:type="field.type === FieldType.PASSWORD ? 'password' : field.type === FieldType.EMAIL ? 'email' : field.type === FieldType.URL ? 'url' : 'text'"

View File

@@ -0,0 +1,170 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Check, ChevronsUpDown, X } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import type { FieldConfig } from '@/types/field-types'
interface Props {
field: FieldConfig
modelValue: string | null // The ID of the selected record
readonly?: boolean
baseUrl?: string // Base API URL, defaults to '/api/central'
}
const props = withDefaults(defineProps<Props>(), {
baseUrl: '/api/central',
modelValue: null,
})
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const { $api } = useNuxtApp() as unknown as { $api: Function }
const open = ref(false)
const searchQuery = ref('')
const records = ref<any[]>([])
const loading = ref(false)
const selectedRecord = ref<any | null>(null)
// Get the relation configuration
const relationObject = computed(() => props.field.relationObject || props.field.apiName.replace('Id', ''))
const displayField = computed(() => props.field.relationDisplayField || 'name')
// Display value for the selected record
const displayValue = computed(() => {
if (!selectedRecord.value) return 'Select...'
return selectedRecord.value[displayField.value] || selectedRecord.value.id
})
// Filtered records based on search
const filteredRecords = computed(() => {
if (!searchQuery.value) return records.value
const query = searchQuery.value.toLowerCase()
return records.value.filter(record => {
const displayValue = record[displayField.value] || record.id
return displayValue.toLowerCase().includes(query)
})
})
// Fetch available records for the lookup
const fetchRecords = async () => {
loading.value = true
try {
const response = await $api(`${props.baseUrl}/${relationObject.value}`)
records.value = response || []
// If we have a modelValue, find the selected record
if (props.modelValue) {
selectedRecord.value = records.value.find(r => r.id === props.modelValue) || null
}
} catch (err) {
console.error('Error fetching lookup records:', err)
} finally {
loading.value = false
}
}
// Handle record selection
const selectRecord = (record: any) => {
selectedRecord.value = record
emit('update:modelValue', record.id)
open.value = false
}
// Clear selection
const clearSelection = () => {
selectedRecord.value = null
emit('update:modelValue', null)
}
// Watch for external modelValue changes
watch(() => props.modelValue, (newValue) => {
if (newValue && records.value.length > 0) {
selectedRecord.value = records.value.find(r => r.id === newValue) || null
} else if (!newValue) {
selectedRecord.value = null
}
})
onMounted(() => {
fetchRecords()
})
</script>
<template>
<div class="lookup-field space-y-2">
<Popover v-model:open="open">
<div class="flex gap-2">
<PopoverTrigger as-child>
<Button
variant="outline"
role="combobox"
:aria-expanded="open"
:disabled="readonly || loading"
class="flex-1 justify-between"
>
<span class="truncate">{{ displayValue }}</span>
<ChevronsUpDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<Button
v-if="selectedRecord && !readonly"
variant="outline"
size="icon"
@click="clearSelection"
class="shrink-0"
>
<X class="h-4 w-4" />
</Button>
</div>
<PopoverContent class="w-[400px] p-0">
<Command>
<CommandInput
v-model="searchQuery"
placeholder="Search..."
/>
<CommandEmpty>
{{ loading ? 'Loading...' : 'No results found.' }}
</CommandEmpty>
<CommandList>
<CommandGroup>
<CommandItem
v-for="record in filteredRecords"
:key="record.id"
:value="record.id"
@select="selectRecord(record)"
>
<Check
:class="cn(
'mr-2 h-4 w-4',
selectedRecord?.id === record.id ? 'opacity-100' : 'opacity-0'
)"
/>
{{ record[displayField] || record.id }}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<!-- Display readonly value -->
<div v-if="readonly && selectedRecord" class="text-sm text-muted-foreground">
{{ selectedRecord[displayField] || selectedRecord.id }}
</div>
</div>
</template>
<style scoped>
.lookup-field {
width: 100%;
}
</style>

View File

@@ -4,7 +4,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
import { DetailViewConfig, ViewMode, FieldSection } from '@/types/field-types'
import RelatedList from '@/components/RelatedList.vue'
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig, RelatedListConfig } from '@/types/field-types'
import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
import {
Collapsible,
@@ -13,7 +14,7 @@ import {
} from '@/components/ui/collapsible'
interface Props {
config: DetailViewConfig
config: DetailViewConfig & { relatedLists?: RelatedListConfig[] }
data: any
loading?: boolean
}
@@ -27,6 +28,8 @@ const emit = defineEmits<{
'delete': []
'back': []
'action': [actionId: string]
'navigate': [objectApiName: string, recordId: string]
'createRelated': [objectApiName: string, parentId: string]
}>()
// Organize fields into sections
@@ -47,7 +50,7 @@ const sections = computed<FieldSection[]>(() => {
const getFieldsBySection = (section: FieldSection) => {
return section.fields
.map(apiName => props.config.fields.find(f => f.apiName === apiName))
.filter(Boolean)
.filter((field): field is FieldConfig => field !== undefined)
}
</script>
@@ -121,6 +124,7 @@ const getFieldsBySection = (section: FieldSection) => {
:key="field.id"
:field="field"
:model-value="data[field.apiName]"
:record-data="data"
:mode="ViewMode.DETAIL"
/>
</div>
@@ -139,9 +143,10 @@ const getFieldsBySection = (section: FieldSection) => {
<div class="grid gap-6 md:grid-cols-2">
<FieldRenderer
v-for="field in getFieldsBySection(section)"
:key="field?.id"
:key="field.id"
:field="field"
:model-value="data[field.apiName]"
:record-data="data"
:mode="ViewMode.DETAIL"
/>
</div>
@@ -149,6 +154,19 @@ const getFieldsBySection = (section: FieldSection) => {
</template>
</Card>
</div>
<!-- Related Lists -->
<div v-if="config.relatedLists && config.relatedLists.length > 0" class="space-y-6">
<RelatedList
v-for="relatedList in config.relatedLists"
:key="relatedList.relationName"
:config="relatedList"
:parent-id="data.id"
:related-records="data[relatedList.relationName]"
@navigate="(objectApiName, recordId) => emit('navigate', objectApiName, recordId)"
@create="(objectApiName, parentId) => emit('createRelated', objectApiName, parentId)"
/>
</div>
</div>
</template>

View File

@@ -4,7 +4,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Button } from '@/components/ui/button'
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
import PageLayoutRenderer from '@/components/PageLayoutRenderer.vue'
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig } from '@/types/field-types'
import RelatedList from '@/components/RelatedList.vue'
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig, RelatedListConfig } from '@/types/field-types'
import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
import {
Collapsible,
@@ -29,6 +30,8 @@ const emit = defineEmits<{
'delete': []
'back': []
'action': [actionId: string]
'navigate': [objectApiName: string, recordId: string]
'createRelated': [objectApiName: string, parentId: string]
}>()
const { getDefaultPageLayout } = usePageLayouts()
@@ -165,6 +168,7 @@ const usePageLayout = computed(() => {
:key="field.id"
:field="field"
:model-value="data[field.apiName]"
:record-data="data"
:mode="ViewMode.DETAIL"
/>
</div>
@@ -186,6 +190,7 @@ const usePageLayout = computed(() => {
:key="field?.id"
:field="field"
:model-value="data[field.apiName]"
:record-data="data"
:mode="ViewMode.DETAIL"
/>
</div>
@@ -193,6 +198,19 @@ const usePageLayout = computed(() => {
</template>
</Card>
</div>
<!-- Related Lists -->
<div v-if="config.relatedLists && config.relatedLists.length > 0" class="space-y-6">
<RelatedList
v-for="relatedList in config.relatedLists"
:key="relatedList.relationName"
:config="relatedList"
:parent-id="data.id"
:related-records="data[relatedList.relationName]"
@navigate="(objectApiName, recordId) => emit('navigate', objectApiName, recordId)"
@create="(objectApiName, parentId) => emit('createRelated', objectApiName, parentId)"
/>
</div>
</div>
</template>

View File

@@ -205,6 +205,7 @@ const handleAction = (actionId: string) => {
<FieldRenderer
:field="field"
:model-value="row[field.apiName]"
:record-data="row"
:mode="ViewMode.LIST"
/>
</TableCell>