Added auth functionality, initial work with views and field types
This commit is contained in:
273
frontend/components/views/EditView.vue
Normal file
273
frontend/components/views/EditView.vue
Normal file
@@ -0,0 +1,273 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
|
||||
import { EditViewConfig, ViewMode, FieldSection, FieldValidationRule } from '@/types/field-types'
|
||||
import { Save, X, ArrowLeft } from 'lucide-vue-next'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
|
||||
interface Props {
|
||||
config: EditViewConfig
|
||||
data?: any
|
||||
loading?: boolean
|
||||
saving?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
data: () => ({}),
|
||||
loading: false,
|
||||
saving: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'save': [data: any]
|
||||
'cancel': []
|
||||
'back': []
|
||||
}>()
|
||||
|
||||
// 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 })
|
||||
|
||||
// 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
|
||||
return [{
|
||||
title: 'Details',
|
||||
fields: props.config.fields
|
||||
.filter(f => f.showOnEdit !== false)
|
||||
.map(f => f.apiName),
|
||||
}]
|
||||
})
|
||||
|
||||
const getFieldsBySection = (section: FieldSection) => {
|
||||
return section.fields
|
||||
.map(apiName => props.config.fields.find(f => f.apiName === apiName))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
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 'email':
|
||||
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
||||
return rule.message || `${field.label} must be a valid email`
|
||||
}
|
||||
break
|
||||
case 'url':
|
||||
if (value && !/^https?:\/\/.+/.test(value)) {
|
||||
return rule.message || `${field.label} must be a valid URL`
|
||||
}
|
||||
break
|
||||
case 'pattern':
|
||||
if (value && !new RegExp(rule.value).test(value)) {
|
||||
return rule.message || `${field.label} has invalid format`
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
errors.value = {}
|
||||
let isValid = true
|
||||
|
||||
for (const field of props.config.fields) {
|
||||
const error = validateField(field)
|
||||
if (error) {
|
||||
errors.value[field.apiName] = error
|
||||
isValid = false
|
||||
}
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (validateForm()) {
|
||||
emit('save', { ...formData.value })
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
formData.value = { ...props.data }
|
||||
errors.value = {}
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
const updateFieldValue = (apiName: string, value: any) => {
|
||||
formData.value[apiName] = value
|
||||
// Clear error for this field when user starts editing
|
||||
if (errors.value[apiName]) {
|
||||
delete errors.value[apiName]
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="edit-view 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' : 'Create' }} {{ config.objectApiName }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" @click="handleCancel" :disabled="saving">
|
||||
<X class="h-4 w-4 mr-2" />
|
||||
{{ config.cancelLabel || 'Cancel' }}
|
||||
</Button>
|
||||
<Button @click="handleSave" :disabled="saving">
|
||||
<Save class="h-4 w-4 mr-2" />
|
||||
{{ config.submitLabel || 'Save' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" 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>
|
||||
|
||||
<!-- Form Sections -->
|
||||
<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"
|
||||
class="space-y-1"
|
||||
>
|
||||
<FieldRenderer
|
||||
:field="field"
|
||||
:model-value="formData[field.apiName]"
|
||||
:mode="ViewMode.EDIT"
|
||||
@update:model-value="updateFieldValue(field.apiName, $event)"
|
||||
/>
|
||||
<p v-if="errors[field.apiName]" class="text-sm text-destructive">
|
||||
{{ errors[field.apiName] }}
|
||||
</p>
|
||||
</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"
|
||||
class="space-y-1"
|
||||
>
|
||||
<FieldRenderer
|
||||
:field="field"
|
||||
:model-value="formData[field.apiName]"
|
||||
:mode="ViewMode.EDIT"
|
||||
@update:model-value="updateFieldValue(field.apiName, $event)"
|
||||
/>
|
||||
<p v-if="errors[field.apiName]" class="text-sm text-destructive">
|
||||
{{ errors[field.apiName] }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Hidden submit button for form submission -->
|
||||
<button type="submit" class="hidden" />
|
||||
</form>
|
||||
|
||||
<!-- Save indicator -->
|
||||
<div v-if="saving" class="fixed bottom-4 right-4 bg-primary text-primary-foreground px-4 py-2 rounded-lg shadow-lg flex items-center gap-2">
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary-foreground"></div>
|
||||
<span>Saving...</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.edit-view {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user