255 lines
8.8 KiB
Vue
255 lines
8.8 KiB
Vue
<script setup lang="ts">
|
|
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'
|
|
import { Checkbox } from '@/components/ui/checkbox'
|
|
import { Switch } from '@/components/ui/switch'
|
|
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 = withDefaults(defineProps<Props>(), {
|
|
// Default to runtime objects endpoint; override when consuming central entities
|
|
baseUrl: '/runtime/objects',
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: any]
|
|
}>()
|
|
|
|
const { api } = useApi()
|
|
|
|
const value = computed({
|
|
get: () => props.modelValue,
|
|
set: (val) => emit('update:modelValue', val),
|
|
})
|
|
|
|
const isReadOnly = computed(() => props.readonly || props.field.isReadOnly || props.mode === ViewMode.DETAIL)
|
|
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:
|
|
return val instanceof Date ? val.toLocaleString() : new Date(val).toLocaleString()
|
|
case FieldType.BOOLEAN:
|
|
return val ? 'Yes' : 'No'
|
|
case FieldType.CURRENCY:
|
|
return `${props.field.prefix || '$'}${Number(val).toFixed(2)}${props.field.suffix || ''}`
|
|
case FieldType.SELECT:
|
|
const option = props.field.options?.find(opt => opt.value === val)
|
|
return option?.label || val
|
|
case FieldType.MULTI_SELECT:
|
|
if (!Array.isArray(val)) return '-'
|
|
return val.map(v => {
|
|
const opt = props.field.options?.find(o => o.value === v)
|
|
return opt?.label || v
|
|
}).join(', ')
|
|
default:
|
|
return String(val)
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="field-renderer space-y-2">
|
|
<!-- Label (shown in edit and detail modes) -->
|
|
<Label v-if="!isListMode" :for="field.id" class="flex items-center gap-2">
|
|
{{ field.label }}
|
|
<span v-if="field.isRequired && isEditMode" class="text-destructive">*</span>
|
|
</Label>
|
|
|
|
<!-- Help Text -->
|
|
<p v-if="field.helpText && !isListMode" class="text-sm text-muted-foreground">
|
|
{{ field.helpText }}
|
|
</p>
|
|
|
|
<!-- List View - Simple text display -->
|
|
<div v-if="isListMode" class="text-sm truncate">
|
|
<Badge v-if="field.type === FieldType.BOOLEAN" :variant="value ? 'default' : 'secondary'">
|
|
{{ formatValue(value) }}
|
|
</Badge>
|
|
<template v-else>
|
|
|
|
{{ formatValue(value) }}
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Detail View - Formatted display -->
|
|
<div v-else-if="isDetailMode" class="space-y-1">
|
|
<div v-if="field.type === FieldType.BOOLEAN" class="flex items-center gap-2">
|
|
<Checkbox :checked="value" disabled />
|
|
<span class="text-sm">{{ formatValue(value) }}</span>
|
|
</div>
|
|
<div v-else-if="field.type === FieldType.MULTI_SELECT" class="flex flex-wrap gap-2">
|
|
<Badge v-for="(item, idx) in value" :key="idx" variant="secondary">
|
|
{{ props.field.options?.find(opt => opt.value === item)?.label || item }}
|
|
</Badge>
|
|
</div>
|
|
<div v-else-if="field.type === FieldType.URL && value" class="text-sm">
|
|
<a :href="value" target="_blank" class="text-primary hover:underline">
|
|
{{ value }}
|
|
</a>
|
|
</div>
|
|
<div v-else-if="field.type === FieldType.EMAIL && value" class="text-sm">
|
|
<a :href="`mailto:${value}`" class="text-primary hover:underline">
|
|
{{ value }}
|
|
</a>
|
|
</div>
|
|
<div v-else-if="field.type === FieldType.MARKDOWN && value" class="prose prose-sm">
|
|
<div v-html="value" />
|
|
</div>
|
|
<div v-else class="text-sm font-medium">
|
|
{{ formatValue(value) }}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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-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'"
|
|
:placeholder="field.placeholder"
|
|
:required="field.isRequired"
|
|
:disabled="field.isReadOnly"
|
|
/>
|
|
|
|
<!-- Textarea -->
|
|
<Textarea
|
|
v-else-if="field.type === FieldType.TEXTAREA || field.type === FieldType.MARKDOWN"
|
|
:id="field.id"
|
|
v-model="value"
|
|
:placeholder="field.placeholder"
|
|
:rows="field.rows || 4"
|
|
:required="field.isRequired"
|
|
:disabled="field.isReadOnly"
|
|
/>
|
|
|
|
<!-- Number Input -->
|
|
<Input
|
|
v-else-if="[FieldType.NUMBER, FieldType.CURRENCY].includes(field.type)"
|
|
:id="field.id"
|
|
v-model.number="value"
|
|
type="number"
|
|
:placeholder="field.placeholder"
|
|
:min="field.min"
|
|
:max="field.max"
|
|
:step="field.step || (field.type === FieldType.CURRENCY ? 0.01 : 1)"
|
|
:required="field.isRequired"
|
|
:disabled="field.isReadOnly"
|
|
/>
|
|
|
|
<!-- Select -->
|
|
<Select v-else-if="field.type === FieldType.SELECT" v-model="value">
|
|
<SelectTrigger :id="field.id">
|
|
<SelectValue :placeholder="field.placeholder || 'Select an option'" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem v-for="option in field.options" :key="String(option.value)" :value="String(option.value)">
|
|
{{ option.label }}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<!-- Boolean - Checkbox -->
|
|
<div v-else-if="field.type === FieldType.BOOLEAN" class="flex items-center gap-2">
|
|
<Checkbox :id="field.id" v-model:checked="value" :disabled="field.isReadOnly" />
|
|
<Label :for="field.id" class="text-sm font-normal cursor-pointer">
|
|
{{ field.placeholder || field.label }}
|
|
</Label>
|
|
</div>
|
|
|
|
<!-- Date Picker -->
|
|
<DatePicker
|
|
v-else-if="[FieldType.DATE, FieldType.DATETIME].includes(field.type)"
|
|
v-model="value"
|
|
:placeholder="field.placeholder"
|
|
:disabled="field.isReadOnly"
|
|
/>
|
|
|
|
<!-- Fallback -->
|
|
<Input
|
|
v-else
|
|
:id="field.id"
|
|
v-model="value"
|
|
:placeholder="field.placeholder"
|
|
:required="field.isRequired"
|
|
:disabled="field.isReadOnly"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Read-only Edit View -->
|
|
<div v-else-if="isEditMode && isReadOnly" class="text-sm text-muted-foreground">
|
|
{{ formatValue(value) }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.field-renderer {
|
|
width: 100%;
|
|
}
|
|
</style>
|