Add record access strategy
This commit is contained in:
@@ -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,27 @@ 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: '/central',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: any]
|
||||
}>()
|
||||
|
||||
const { api } = useApi()
|
||||
|
||||
const value = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val) => emit('update:modelValue', val),
|
||||
@@ -32,10 +40,44 @@ 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 from field apiName (e.g., 'ownerId' -> 'owner')
|
||||
const getRelationPropertyName = () => {
|
||||
// Backend attaches related object using field apiName without 'Id' suffix, lowercase
|
||||
// e.g., ownerId -> owner, accountId -> account
|
||||
return props.field.apiName.replace(/Id$/, '').toLowerCase()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
// If no related object found in recordData, just show the ID
|
||||
// (The fetch mechanism is removed to avoid N+1 queries)
|
||||
return props.modelValue || '-'
|
||||
})
|
||||
|
||||
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:
|
||||
@@ -78,6 +120,7 @@ const formatValue = (val: any): string => {
|
||||
{{ formatValue(value) }}
|
||||
</Badge>
|
||||
<template v-else>
|
||||
|
||||
{{ formatValue(value) }}
|
||||
</template>
|
||||
</div>
|
||||
@@ -113,9 +156,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'"
|
||||
|
||||
171
frontend/components/fields/LookupField.vue
Normal file
171
frontend/components/fields/LookupField.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<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 '/central'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
baseUrl: '/central',
|
||||
modelValue: null,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [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)
|
||||
|
||||
// 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 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
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
Reference in New Issue
Block a user