324 lines
10 KiB
Vue
324 lines
10 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, watch, onMounted } from 'vue'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
|
|
import PageLayoutRenderer from '@/components/PageLayoutRenderer.vue'
|
|
import { EditViewConfig, ViewMode, FieldSection, FieldConfig } from '@/types/field-types'
|
|
import { Save, X, ArrowLeft } from 'lucide-vue-next'
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from '@/components/ui/collapsible'
|
|
import type { PageLayoutConfig } from '~/types/page-layout'
|
|
|
|
interface Props {
|
|
config: EditViewConfig
|
|
data?: any
|
|
loading?: boolean
|
|
saving?: boolean
|
|
objectId?: string // For fetching page layout
|
|
baseUrl?: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
data: () => ({}),
|
|
loading: false,
|
|
saving: false,
|
|
baseUrl: '/runtime/objects',
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
'save': [data: any]
|
|
'cancel': []
|
|
'back': []
|
|
}>()
|
|
|
|
const { getDefaultPageLayout } = usePageLayouts()
|
|
const pageLayout = ref<PageLayoutConfig | null>(null)
|
|
const loadingLayout = ref(false)
|
|
|
|
// Form data
|
|
const formData = ref<Record<string, any>>({ ...props.data })
|
|
const errors = ref<Record<string, string>>({})
|
|
|
|
// Watch for data changes (useful for edit mode)
|
|
watch(() => props.data, (newData) => {
|
|
formData.value = { ...newData }
|
|
}, { deep: true })
|
|
|
|
// Fetch page layout if objectId is provided
|
|
onMounted(async () => {
|
|
if (props.objectId) {
|
|
try {
|
|
loadingLayout.value = true
|
|
const layout = await getDefaultPageLayout(props.objectId)
|
|
if (layout) {
|
|
// Handle both camelCase and snake_case
|
|
pageLayout.value = layout.layoutConfig || layout.layout_config
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading page layout:', error)
|
|
} finally {
|
|
loadingLayout.value = false
|
|
}
|
|
}
|
|
})
|
|
|
|
// Organize fields into sections
|
|
const sections = computed<FieldSection[]>(() => {
|
|
if (props.config.sections && props.config.sections.length > 0) {
|
|
return props.config.sections
|
|
}
|
|
|
|
// Default section with all visible fields
|
|
const visibleFields = props.config.fields
|
|
.filter(f => f.showOnEdit !== false)
|
|
.map(f => f.apiName)
|
|
|
|
return [{
|
|
title: 'Details',
|
|
fields: visibleFields,
|
|
}]
|
|
})
|
|
|
|
const getFieldsBySection = (section: FieldSection) => {
|
|
const fields = section.fields
|
|
.map(apiName => props.config.fields.find(f => f.apiName === apiName))
|
|
.filter((field): field is FieldConfig => field !== undefined)
|
|
|
|
return fields
|
|
}
|
|
|
|
// Use page layout if available, otherwise fall back to sections
|
|
const usePageLayout = computed(() => {
|
|
return pageLayout.value && pageLayout.value.fields && pageLayout.value.fields.length > 0
|
|
})
|
|
|
|
const validateField = (field: any): string | null => {
|
|
const value = formData.value[field.apiName]
|
|
|
|
// Required validation
|
|
if (field.isRequired && (value === null || value === undefined || value === '')) {
|
|
return `${field.label} is required`
|
|
}
|
|
|
|
// Custom validation rules
|
|
if (field.validationRules) {
|
|
for (const rule of field.validationRules) {
|
|
switch (rule.type) {
|
|
case 'required':
|
|
if (value === null || value === undefined || value === '') {
|
|
return rule.message || `${field.label} is required`
|
|
}
|
|
break
|
|
case 'min':
|
|
if (typeof value === 'number' && value < rule.value) {
|
|
return rule.message || `${field.label} must be at least ${rule.value}`
|
|
}
|
|
if (typeof value === 'string' && value.length < rule.value) {
|
|
return rule.message || `${field.label} must be at least ${rule.value} characters`
|
|
}
|
|
break
|
|
case 'max':
|
|
if (typeof value === 'number' && value > rule.value) {
|
|
return rule.message || `${field.label} must be at most ${rule.value}`
|
|
}
|
|
if (typeof value === 'string' && value.length > rule.value) {
|
|
return rule.message || `${field.label} must be at most ${rule.value} characters`
|
|
}
|
|
break
|
|
case 'pattern':
|
|
if (value && !new RegExp(rule.value).test(value)) {
|
|
return rule.message || `${field.label} format is invalid`
|
|
}
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
const validateForm = (): boolean => {
|
|
errors.value = {}
|
|
let isValid = true
|
|
|
|
for (const field of props.config.fields) {
|
|
if (field.showOnEdit === false) continue
|
|
|
|
const error = validateField(field)
|
|
if (error) {
|
|
errors.value[field.apiName] = error
|
|
isValid = false
|
|
}
|
|
}
|
|
|
|
return isValid
|
|
}
|
|
|
|
const handleSave = () => {
|
|
if (validateForm()) {
|
|
// Start with props.data to preserve system fields like id, then override with user edits
|
|
const saveData = {
|
|
...props.data,
|
|
...formData.value,
|
|
}
|
|
emit('save', saveData)
|
|
}
|
|
}
|
|
|
|
const handleFieldUpdate = (fieldName: string, value: any) => {
|
|
formData.value[fieldName] = value
|
|
// Clear error for this field when user makes changes
|
|
if (errors.value[fieldName]) {
|
|
delete errors.value[fieldName]
|
|
}
|
|
}
|
|
|
|
const handleRelatedFieldsUpdate = (values: Record<string, any>) => {
|
|
formData.value = {
|
|
...formData.value,
|
|
...values,
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="edit-view-enhanced space-y-6">
|
|
<!-- Header -->
|
|
<div class="flex items-center justify-between">
|
|
<div class="flex items-center gap-4">
|
|
<Button variant="ghost" size="sm" @click="emit('back')">
|
|
<ArrowLeft class="h-4 w-4 mr-2" />
|
|
Back
|
|
</Button>
|
|
<div>
|
|
<h2 class="text-2xl font-bold tracking-tight">
|
|
{{ data?.id ? `Edit ${config.objectApiName}` : `New ${config.objectApiName}` }}
|
|
</h2>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex items-center gap-2">
|
|
<Button variant="outline" @click="emit('cancel')" :disabled="saving">
|
|
<X class="h-4 w-4 mr-2" />
|
|
Cancel
|
|
</Button>
|
|
<Button @click="handleSave" :disabled="saving || loading || loadingLayout">
|
|
<Save class="h-4 w-4 mr-2" />
|
|
{{ saving ? 'Saving...' : 'Save' }}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div v-if="loading || loadingLayout" class="flex items-center justify-center py-12">
|
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
|
</div>
|
|
|
|
<!-- Content with Page Layout -->
|
|
<Card v-else-if="usePageLayout">
|
|
<CardHeader>
|
|
<CardTitle>{{ data?.id ? 'Edit Details' : 'New Record' }}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<PageLayoutRenderer
|
|
:fields="config.fields"
|
|
:layout="pageLayout"
|
|
:model-value="formData"
|
|
:readonly="false"
|
|
@update:model-value="formData = $event"
|
|
/>
|
|
|
|
<!-- Display validation errors -->
|
|
<div v-if="Object.keys(errors).length > 0" class="mt-4 p-4 bg-destructive/10 text-destructive rounded-md">
|
|
<p class="font-semibold mb-2">Please fix the following errors:</p>
|
|
<ul class="list-disc list-inside space-y-1">
|
|
<li v-for="(error, field) in errors" :key="field">{{ error }}</li>
|
|
</ul>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<!-- Traditional Section-based Layout -->
|
|
<form v-else @submit.prevent="handleSave" class="space-y-6">
|
|
<Card v-for="(section, idx) in sections" :key="idx">
|
|
<Collapsible
|
|
v-if="section.collapsible"
|
|
:default-open="!section.defaultCollapsed"
|
|
>
|
|
<CardHeader>
|
|
<CollapsibleTrigger class="flex items-center justify-between w-full hover:bg-muted/50 -m-2 p-2 rounded">
|
|
<div>
|
|
<CardTitle v-if="section.title">{{ section.title }}</CardTitle>
|
|
<CardDescription v-if="section.description">
|
|
{{ section.description }}
|
|
</CardDescription>
|
|
</div>
|
|
</CollapsibleTrigger>
|
|
</CardHeader>
|
|
<CollapsibleContent>
|
|
<CardContent>
|
|
<div class="grid gap-6 md:grid-cols-2">
|
|
<div v-for="field in getFieldsBySection(section)" :key="field.id">
|
|
<FieldRenderer
|
|
:field="field"
|
|
:model-value="formData[field.apiName]"
|
|
:record-data="formData"
|
|
:mode="ViewMode.EDIT"
|
|
:error="errors[field.apiName]"
|
|
:base-url="baseUrl"
|
|
@update:model-value="handleFieldUpdate(field.apiName, $event)"
|
|
@update:related-fields="handleRelatedFieldsUpdate"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
|
|
<template v-else>
|
|
<CardHeader v-if="section.title || section.description">
|
|
<CardTitle v-if="section.title">{{ section.title }}</CardTitle>
|
|
<CardDescription v-if="section.description">
|
|
{{ section.description }}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div class="grid gap-6 md:grid-cols-2">
|
|
<div v-for="field in getFieldsBySection(section)" :key="field?.id">
|
|
<FieldRenderer
|
|
:field="field"
|
|
:model-value="formData[field.apiName]"
|
|
:record-data="formData"
|
|
:mode="ViewMode.EDIT"
|
|
:error="errors[field.apiName]"
|
|
:base-url="baseUrl"
|
|
@update:model-value="handleFieldUpdate(field.apiName, $event)"
|
|
@update:related-fields="handleRelatedFieldsUpdate"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</template>
|
|
</Card>
|
|
|
|
<!-- Display validation errors -->
|
|
<div v-if="Object.keys(errors).length > 0" class="p-4 bg-destructive/10 text-destructive rounded-md">
|
|
<p class="font-semibold mb-2">Please fix the following errors:</p>
|
|
<ul class="list-disc list-inside space-y-1">
|
|
<li v-for="(error, field) in errors" :key="field">{{ error }}</li>
|
|
</ul>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.edit-view-enhanced {
|
|
width: 100%;
|
|
}
|
|
</style>
|