Files
neo/frontend/components/fields/LookupField.vue
2026-01-12 21:08:47 +01:00

237 lines
7.2 KiB
Vue

<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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
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 '/central'
relationTypeValue?: string | null
}
const props = withDefaults(defineProps<Props>(), {
// Default to runtime objects endpoint; override when consuming central entities
baseUrl: '/runtime/objects',
modelValue: null,
})
const emit = defineEmits<{
'update:modelValue': [value: string | null]
'update:relationTypeValue': [value: string | null]
}>()
const { api } = useApi()
const open = ref(false)
const searchQuery = ref('')
const records = ref<any[]>([])
const loading = ref(false)
const selectedRecord = ref<any | null>(null)
const selectedRelationObject = ref<string | null>(null)
// Get the relation configuration
const availableRelationObjects = computed(() => {
if (props.field.relationObjects && props.field.relationObjects.length > 0) {
return props.field.relationObjects
}
const fallback = props.field.relationObject || props.field.apiName.replace('Id', '')
return fallback ? [fallback] : []
})
const relationObject = computed(() => selectedRelationObject.value || availableRelationObjects.value[0])
const displayField = computed(() => props.field.relationDisplayField || 'name')
const shouldShowTypeSelector = computed(() => availableRelationObjects.value.length > 1)
// 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 () => {
if (!relationObject.value) {
records.value = []
return
}
loading.value = true
try {
const endpoint = `${props.baseUrl}/${relationObject.value}/records`
const response = await api.get(endpoint)
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
}
}
const handleRelationTypeChange = (value: string) => {
selectedRelationObject.value = value
emit('update:relationTypeValue', value)
searchQuery.value = ''
selectedRecord.value = null
emit('update:modelValue', null)
fetchRecords()
}
// 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
}
})
watch(() => props.relationTypeValue, (newValue) => {
if (!newValue) return
if (availableRelationObjects.value.includes(newValue)) {
selectedRelationObject.value = newValue
fetchRecords()
}
})
onMounted(() => {
selectedRelationObject.value = props.relationTypeValue && availableRelationObjects.value.includes(props.relationTypeValue)
? props.relationTypeValue
: availableRelationObjects.value[0] || null
// Emit initial relation type if we have a default selection so hidden relationTypeField gets populated
if (selectedRelationObject.value) {
emit('update:relationTypeValue', selectedRelationObject.value)
}
fetchRecords()
})
</script>
<template>
<div class="lookup-field space-y-2">
<Popover v-model:open="open">
<div class="flex gap-2">
<Select
v-if="shouldShowTypeSelector"
:model-value="relationObject"
:disabled="readonly || loading"
@update:model-value="handleRelationTypeChange"
>
<SelectTrigger class="w-40">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in availableRelationObjects"
:key="option"
:value="option"
>
{{ option }}
</SelectItem>
</SelectContent>
</Select>
<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="relationObject ? `Search ${relationObject}...` : '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>