Files
neo/frontend/pages/setup/objects/[apiName].vue

760 lines
26 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="min-h-screen bg-background">
<NuxtLayout name="default">
<main class="container mx-auto px-4 py-8">
<div v-if="loading" class="text-center py-12">Loading...</div>
<div v-else-if="error" class="text-destructive">Error: {{ error }}</div>
<div v-else-if="object">
<div class="mb-6">
<NuxtLink to="/setup/objects" class="text-sm text-primary hover:underline">
Back to Objects
</NuxtLink>
</div>
<h1 class="text-3xl font-bold mb-6">{{ object.label }}</h1>
<!-- Tabs -->
<div class="mb-8">
<Tabs v-model="activeTab" default-value="fields" class="w-full">
<TabsList class="grid w-full grid-cols-3 max-w-2xl">
<TabsTrigger value="fields">Fields</TabsTrigger>
<TabsTrigger value="access">Access</TabsTrigger>
<TabsTrigger value="layouts">Page Layouts</TabsTrigger>
</TabsList>
<!-- Fields Tab -->
<TabsContent value="fields" class="mt-6">
<div class="space-y-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">Fields</h2>
<Button @click="openFieldDialog('create')">
<Plus class="w-4 h-4 mr-2" />
New Field
</Button>
</div>
<div v-if="!object.fields || object.fields.length === 0" class="text-center py-8 text-muted-foreground">
No fields defined yet. Create one to get started.
</div>
<div v-else class="space-y-2">
<div
v-for="field in object.fields"
:key="field.id"
class="p-4 border rounded-lg bg-card hover:border-primary transition-colors"
>
<div class="flex items-center justify-between">
<div class="flex-1">
<h3 class="font-semibold">{{ field.label }}</h3>
<p class="text-sm text-muted-foreground">
Type: <span class="font-medium">{{ formatFieldType(field.type) }}</span> | API Name: <span class="font-mono">{{ field.apiName }}</span>
</p>
<p v-if="field.description" class="text-sm text-muted-foreground mt-1">
{{ field.description }}
</p>
</div>
<div class="flex items-center gap-3">
<div class="flex gap-2 text-xs">
<span
v-if="field.isRequired"
class="px-2 py-1 bg-destructive/10 text-destructive rounded"
>
Required
</span>
<span
v-if="field.isUnique"
class="px-2 py-1 bg-primary/10 text-primary rounded"
>
Unique
</span>
<span
v-if="field.isSystem"
class="px-2 py-1 bg-gray-200 text-gray-700 rounded text-xs"
>
System
</span>
</div>
<div class="flex gap-2">
<Button
v-if="!field.isSystem"
variant="ghost"
size="sm"
@click="openFieldDialog('edit', field)"
title="Edit field"
>
</Button>
<Button
v-if="!field.isSystem"
variant="ghost"
size="sm"
class="text-destructive hover:text-destructive"
@click="deleteField(field)"
title="Delete field"
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
</div>
</div>
</TabsContent>
<!-- Access Tab -->
<TabsContent value="access" class="mt-6">
<ObjectAccessSettings
:object-api-name="object.apiName"
:object-id="object.id"
:org-wide-default="object.orgWideDefault"
:fields="object.fields"
@update="handleAccessUpdate"
/>
</TabsContent>
<!-- Page Layouts Tab -->
<TabsContent value="layouts" class="mt-6">
<div v-if="!selectedLayout" class="space-y-4">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold">Page Layouts</h2>
<Button @click="handleCreateLayout">
<Plus class="w-4 h-4 mr-2" />
New Layout
</Button>
</div>
<div v-if="loadingLayouts" class="text-center py-8">
Loading layouts...
</div>
<div v-else-if="layouts.length === 0" class="text-center py-8 text-muted-foreground">
No page layouts yet. Create one to get started.
</div>
<div v-else class="space-y-2">
<div
v-for="layout in layouts"
:key="layout.id"
class="p-4 border rounded-lg bg-card hover:border-primary cursor-pointer transition-colors"
@click="handleSelectLayout(layout)"
>
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold">{{ layout.name }}</h3>
<p v-if="layout.description" class="text-sm text-muted-foreground">
{{ layout.description }}
</p>
</div>
<div class="flex items-center gap-2">
<span
v-if="layout.isDefault"
class="px-2 py-1 bg-primary/10 text-primary rounded text-xs"
>
Default
</span>
<Button
variant="ghost"
size="sm"
@click.stop="handleDeleteLayout(layout.id)"
>
<Trash2 class="w-4 h-4" />
</Button>
</div>
</div>
</div>
</div>
</div>
<!-- Layout Editor -->
<div v-else>
<div class="mb-4">
<Button variant="outline" @click="selectedLayout = null">
<ArrowLeft class="w-4 h-4 mr-2" />
Back to Layouts
</Button>
</div>
<PageLayoutEditor
:fields="object.fields"
:initial-layout="(selectedLayout.layoutConfig || selectedLayout.layout_config)?.fields || []"
:related-lists="object.relatedLists || []"
:initial-related-lists="(selectedLayout.layoutConfig || selectedLayout.layout_config)?.relatedLists || []"
:layout-name="selectedLayout.name"
@save="handleSaveLayout"
/>
</div>
</TabsContent>
</Tabs>
</div>
</div>
</main>
<!-- Field Management Dialog -->
<Teleport to="body">
<div
v-if="showFieldDialog"
class="fixed inset-0 bg-black/50 flex items-center justify-center z-[100]"
>
<div class="bg-white rounded-lg shadow-lg max-w-3xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div class="sticky top-0 bg-white border-b p-6 flex items-center justify-between">
<h2 class="text-2xl font-bold">
{{ fieldDialogMode === 'create' ? 'Create New Field' : 'Edit Field' }}
</h2>
<button
@click="closeFieldDialog"
class="text-gray-500 hover:text-gray-700 text-2xl font-bold"
>
×
</button>
</div>
<div class="p-6 space-y-6">
<!-- Field Type Selection (only for creation) -->
<div v-if="fieldDialogMode === 'create'">
<FieldTypeSelector
v-model="fieldForm.type"
/>
</div>
<!-- Common Attributes -->
<div v-if="fieldForm.type">
<h3 class="text-lg font-semibold mb-4">Basic Properties</h3>
<FieldAttributesCommon
:label="fieldForm.label"
:api-name="fieldForm.apiName"
:description="fieldForm.description"
:placeholder="fieldForm.placeholder"
:help-text="fieldForm.helpText"
:display-order="fieldForm.displayOrder"
:is-required="fieldForm.isRequired"
:is-unique="fieldForm.isUnique"
:default-value="fieldForm.defaultValue"
:is-editing="fieldDialogMode === 'edit'"
:has-data="fieldForm.hasData"
@update="updateCommonAttributes"
/>
</div>
<!-- Type-Specific Attributes -->
<div v-if="fieldForm.type">
<h3 class="text-lg font-semibold mb-4">Type-Specific Settings</h3>
<FieldAttributesType
:field-type="fieldForm.type"
:attributes="fieldForm.typeAttributes"
@update="updateTypeAttributes"
/>
</div>
<!-- Lookup Field Selection -->
<div v-if="(fieldForm.type === 'lookup' || fieldForm.type === 'belongsTo') && fieldDialogMode === 'create'">
<h3 class="text-lg font-semibold mb-4">Related Object</h3>
<div class="grid grid-cols-4 gap-4">
<label class="text-sm font-medium leading-8">Select Object</label>
<div class="col-span-3">
<select
v-model="fieldForm.referenceObject"
class="w-full px-3 py-2 border rounded-md text-sm"
>
<option value="">-- Select an object --</option>
<option
v-for="obj in availableObjects"
:key="obj.id"
:value="obj.apiName"
>
{{ obj.label }} ({{ obj.apiName }})
</option>
</select>
</div>
</div>
</div>
<!-- Error Message -->
<div v-if="fieldDialogError" class="p-3 bg-red-100 text-red-800 rounded-md text-sm">
{{ fieldDialogError }}
</div>
<!-- Action Buttons -->
<div class="flex gap-3 justify-end pt-4">
<Button variant="outline" @click="closeFieldDialog">
Cancel
</Button>
<Button
:disabled="!fieldForm.label || !fieldForm.apiName || !fieldForm.type"
@click="saveField"
>
{{ fieldDialogMode === 'create' ? 'Create Field' : 'Update Field' }}
</Button>
</div>
</div>
</div>
</div>
</Teleport>
</NuxtLayout>
</div>
</template>
<script setup lang="ts">
import { Plus, Trash2, ArrowLeft } from 'lucide-vue-next'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import PageLayoutEditor from '@/components/PageLayoutEditor.vue'
import ObjectAccessSettings from '@/components/ObjectAccessSettings.vue'
import FieldTypeSelector from '@/components/fields/FieldTypeSelector.vue'
import FieldAttributesCommon from '@/components/fields/FieldAttributesCommon.vue'
import FieldAttributesType from '@/components/fields/FieldAttributesType.vue'
import type { PageLayout, FieldLayoutItem } from '~/types/page-layout'
const route = useRoute()
const { api } = useApi()
const { getPageLayouts, createPageLayout, updatePageLayout, deletePageLayout } = usePageLayouts()
const { toast } = useToast()
const object = ref<any>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const activeTab = ref('fields')
// Page layouts state
const layouts = ref<PageLayout[]>([])
const loadingLayouts = ref(false)
const selectedLayout = ref<PageLayout | null>(null)
// Field management state
const showFieldDialog = ref(false)
const fieldDialogMode = ref<'create' | 'edit'>('create')
const fieldDialogError = ref<string | null>(null)
const availableObjects = ref<any[]>([])
const fieldForm = ref({
id: '',
label: '',
apiName: '',
type: '',
description: '',
placeholder: '',
helpText: '',
displayOrder: 0,
isRequired: false,
isUnique: false,
defaultValue: '',
referenceObject: '',
typeAttributes: {},
hasData: false,
})
// Helper to format field type names
const formatFieldType = (type: string): string => {
const typeNames: Record<string, string> = {
'TEXT': 'Text',
'LONG_TEXT': 'Textarea',
'EMAIL': 'Email',
'PHONE': 'Phone',
'NUMBER': 'Number',
'CURRENCY': 'Currency',
'PERCENT': 'Percent',
'PICKLIST': 'Picklist',
'MULTI_PICKLIST': 'Multi-select',
'BOOLEAN': 'Checkbox',
'DATE': 'Date',
'DATE_TIME': 'DateTime',
'TIME': 'Time',
'URL': 'URL',
'LOOKUP': 'Lookup',
'FILE': 'File',
'IMAGE': 'Image',
'JSON': 'JSON',
}
return typeNames[type] || type
}
const convertFrontendToBackendType = (frontendType: string): string => {
const typeMap: Record<string, string> = {
'text': 'TEXT',
'textarea': 'LONG_TEXT',
'password': 'TEXT',
'email': 'EMAIL',
'number': 'NUMBER',
'currency': 'CURRENCY',
'percent': 'PERCENT',
'select': 'PICKLIST',
'multiSelect': 'MULTI_PICKLIST',
'boolean': 'BOOLEAN',
'date': 'DATE',
'datetime': 'DATE_TIME',
'time': 'TIME',
'url': 'URL',
'color': 'TEXT',
'json': 'JSON',
'lookup': 'LOOKUP',
'belongsTo': 'LOOKUP',
'markdown': 'LONG_TEXT',
'code': 'LONG_TEXT',
'file': 'FILE',
'image': 'IMAGE',
}
return typeMap[frontendType] || 'TEXT'
}
const fetchObject = async () => {
try {
loading.value = true
const apiName = route.params.apiName as string
object.value = await api.get(`/setup/objects/${apiName}`)
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
const fetchAvailableObjects = async () => {
try {
availableObjects.value = await api.get('/setup/objects')
} catch (e: any) {
console.error('Error fetching available objects:', e)
}
}
const fetchLayouts = async () => {
if (!object.value) return
try {
loadingLayouts.value = true
layouts.value = await getPageLayouts(object.value.id)
} catch (e: any) {
console.error('Error fetching layouts:', e)
toast.error('Failed to load page layouts')
} finally {
loadingLayouts.value = false
}
}
const openFieldDialog = async (mode: 'create' | 'edit', field?: any) => {
fieldDialogMode.value = mode
fieldDialogError.value = null
if (mode === 'create') {
await fetchAvailableObjects()
fieldForm.value = {
id: '',
label: '',
apiName: '',
type: '',
description: '',
placeholder: '',
helpText: '',
displayOrder: (object.value?.fields?.length || 0) + 1,
isRequired: false,
isUnique: false,
defaultValue: '',
referenceObject: '',
typeAttributes: {},
hasData: false,
}
} else if (field) {
// Load field data for editing
const uiMetadata = field.ui_metadata ? JSON.parse(field.ui_metadata) : {}
fieldForm.value = {
id: field.id,
label: field.label,
apiName: field.apiName,
type: convertBackendToFrontendType(field.type),
description: field.description || '',
placeholder: uiMetadata.placeholder || '',
helpText: uiMetadata.helpText || '',
displayOrder: field.displayOrder || 0,
isRequired: field.isRequired || false,
isUnique: field.isUnique || false,
defaultValue: field.defaultValue || '',
referenceObject: field.referenceObject || '',
typeAttributes: extractTypeAttributes(field, uiMetadata),
hasData: false, // Would need to fetch this from backend
}
}
showFieldDialog.value = true
}
const convertBackendToFrontendType = (backendType: string): string => {
const typeMap: Record<string, string> = {
'TEXT': 'text',
'LONG_TEXT': 'textarea',
'EMAIL': 'email',
'PHONE': 'phone',
'NUMBER': 'number',
'CURRENCY': 'currency',
'PERCENT': 'percent',
'PICKLIST': 'select',
'MULTI_PICKLIST': 'multiSelect',
'BOOLEAN': 'boolean',
'DATE': 'date',
'DATE_TIME': 'datetime',
'TIME': 'time',
'URL': 'url',
'LOOKUP': 'lookup',
'FILE': 'file',
'IMAGE': 'image',
'JSON': 'json',
}
return typeMap[backendType] || 'text'
}
const extractTypeAttributes = (field: any, uiMetadata: any): any => {
const attrs: any = {}
if (field.type === 'PICKLIST' || field.type === 'MULTI_PICKLIST') {
attrs.options = uiMetadata.options || []
}
if (field.type === 'NUMBER' || field.type === 'CURRENCY') {
attrs.scale = field.scale || 0
attrs.min = uiMetadata.min
attrs.max = uiMetadata.max
if (field.type === 'CURRENCY') {
attrs.prefix = uiMetadata.prefix || '$'
}
}
if (field.type === 'TEXT' && field.length) {
attrs.maxLength = field.length
}
if (field.type === 'LONG_TEXT' && uiMetadata.rows) {
attrs.rows = uiMetadata.rows
}
if (field.type === 'LOOKUP') {
attrs.relationObject = field.referenceObject
attrs.relationDisplayField = uiMetadata.relationDisplayField || 'name'
}
return attrs
}
const closeFieldDialog = () => {
showFieldDialog.value = false
fieldDialogError.value = null
}
const updateCommonAttributes = (data: any) => {
Object.assign(fieldForm.value, data)
}
const updateTypeAttributes = (data: any) => {
fieldForm.value.typeAttributes = data
}
const saveField = async () => {
fieldDialogError.value = null
try {
// Validate
if (!fieldForm.value.label || !fieldForm.value.apiName || !fieldForm.value.type) {
fieldDialogError.value = 'Please fill in all required fields'
return
}
const apiName = route.params.apiName as string
// Prepare payload
const payload: any = {
label: fieldForm.value.label,
apiName: fieldForm.value.apiName,
type: fieldForm.value.type, // Use frontend type, backend will convert
description: fieldForm.value.description,
isRequired: fieldForm.value.isRequired,
isUnique: fieldForm.value.isUnique,
defaultValue: fieldForm.value.defaultValue,
}
// Extract type-specific database fields
const typeAttrs = fieldForm.value.typeAttributes || {}
// For text fields
if (fieldForm.value.type === 'text' && typeAttrs.maxLength) {
payload.length = typeAttrs.maxLength
}
// For number and currency fields
if ((fieldForm.value.type === 'number' || fieldForm.value.type === 'currency') && typeAttrs.scale !== undefined) {
payload.scale = typeAttrs.scale
if (typeAttrs.scale > 0) {
payload.precision = 10 // Default precision for decimals
}
}
// Merge UI metadata
const uiMetadata: any = {
placeholder: fieldForm.value.placeholder,
helpText: fieldForm.value.helpText,
}
// Add type-specific attributes to UI metadata
if (fieldForm.value.typeAttributes) {
Object.assign(uiMetadata, fieldForm.value.typeAttributes)
}
payload.uiMetadata = uiMetadata
if (fieldForm.value.referenceObject) {
payload.relationObject = fieldForm.value.referenceObject
payload.relationDisplayField = fieldForm.value.typeAttributes.relationDisplayField || 'name'
}
let result
if (fieldDialogMode.value === 'create') {
result = await api.post(`/setup/objects/${apiName}/fields`, payload)
} else {
// For updates, only send fields that changed
const updatePayload: any = {}
if (fieldForm.value.label) updatePayload.label = fieldForm.value.label
if (fieldForm.value.description) updatePayload.description = fieldForm.value.description
if (fieldForm.value.placeholder) updatePayload.placeholder = fieldForm.value.placeholder
if (fieldForm.value.helpText) updatePayload.helpText = fieldForm.value.helpText
updatePayload.isRequired = fieldForm.value.isRequired
updatePayload.isUnique = fieldForm.value.isUnique
updatePayload.displayOrder = fieldForm.value.displayOrder
if (Object.keys(uiMetadata).length > 0) {
updatePayload.uiMetadata = uiMetadata
}
result = await api.put(
`/setup/objects/${apiName}/fields/${fieldForm.value.apiName}`,
updatePayload,
)
}
// Update the object with new field
if (fieldDialogMode.value === 'create') {
object.value.fields.push(result)
} else {
const index = object.value.fields.findIndex((f: any) => f.id === fieldForm.value.id)
if (index !== -1) {
object.value.fields[index] = result
}
}
toast.success(
fieldDialogMode.value === 'create'
? 'Field created successfully'
: 'Field updated successfully',
)
closeFieldDialog()
} catch (e: any) {
fieldDialogError.value = e.message || 'An error occurred while saving the field'
console.error('Error saving field:', e)
}
}
const deleteField = async (field: any) => {
if (!confirm(`Are you sure you want to delete the field "${field.label}"? This action cannot be undone.`)) {
return
}
try {
const apiName = route.params.apiName as string
await api.delete(`/setup/objects/${apiName}/fields/${field.apiName}`)
// Remove from the list
object.value.fields = object.value.fields.filter((f: any) => f.id !== field.id)
// Also remove from page layouts
for (const layout of layouts.value) {
const layoutConfig = layout.layoutConfig || layout.layout_config || { fields: [] }
if (layoutConfig.fields) {
layoutConfig.fields = layoutConfig.fields.filter(
(f: any) => f.fieldId !== field.id,
)
}
}
toast.success('Field deleted successfully')
} catch (e: any) {
toast.error(`Failed to delete field: ${e.message}`)
console.error('Error deleting field:', e)
}
}
const handleCreateLayout = async () => {
const name = prompt('Enter a name for the new layout:')
if (!name) return
try {
const newLayout = await createPageLayout({
name,
objectId: object.value.id,
isDefault: layouts.value.length === 0,
layoutConfig: { fields: [], relatedLists: [] },
})
layouts.value.push(newLayout)
selectedLayout.value = newLayout
toast.success('Layout created successfully')
} catch (e: any) {
console.error('Error creating layout:', e)
toast.error('Failed to create layout')
}
}
const handleSelectLayout = (layout: PageLayout) => {
selectedLayout.value = layout
}
const handleSaveLayout = async (layoutConfig: { fields: FieldLayoutItem[]; relatedLists: string[] }) => {
if (!selectedLayout.value) return
try {
const updated = await updatePageLayout(selectedLayout.value.id, {
layoutConfig,
})
// Update the layout in the list
const index = layouts.value.findIndex(l => l.id === selectedLayout.value!.id)
if (index !== -1) {
layouts.value[index] = updated
}
selectedLayout.value = updated
toast.success('Layout saved successfully')
} catch (e: any) {
console.error('Error saving layout:', e)
toast.error('Failed to save layout')
}
}
const handleDeleteLayout = async (layoutId: string) => {
if (!confirm('Are you sure you want to delete this layout?')) return
try {
await deletePageLayout(layoutId)
layouts.value = layouts.value.filter(l => l.id !== layoutId)
toast.success('Layout deleted successfully')
} catch (e: any) {
console.error('Error deleting layout:', e)
toast.error('Failed to delete layout')
}
}
const handleAccessUpdate = (orgWideDefault: string) => {
if (object.value) {
object.value.orgWideDefault = orgWideDefault
}
}
// Watch for tab changes to load layouts
watch(activeTab, (newTab) => {
if (newTab === 'layouts' && layouts.value.length === 0 && !loadingLayouts.value) {
fetchLayouts()
}
})
onMounted(async () => {
await fetchObject()
// If we start on layouts tab, load them
if (activeTab.value === 'layouts') {
await fetchLayouts()
}
})
</script>