Added page layouts
This commit is contained in:
334
frontend/components/PageLayoutEditor.vue
Normal file
334
frontend/components/PageLayoutEditor.vue
Normal file
@@ -0,0 +1,334 @@
|
||||
<template>
|
||||
<div class="page-layout-editor">
|
||||
<div class="flex h-full">
|
||||
<!-- Main Grid Area -->
|
||||
<div class="flex-1 p-4 overflow-auto">
|
||||
<div class="mb-4 flex justify-between items-center">
|
||||
<h3 class="text-lg font-semibold">{{ layoutName || 'Page Layout' }}</h3>
|
||||
<div class="flex gap-2">
|
||||
<Button variant="outline" size="sm" @click="handleClear">
|
||||
Clear All
|
||||
</Button>
|
||||
<Button size="sm" @click="handleSave">
|
||||
Save Layout
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded-lg bg-slate-50 dark:bg-slate-900 p-4 min-h-[600px]">
|
||||
<div
|
||||
ref="gridContainer"
|
||||
class="grid-stack"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@drop="handleDrop"
|
||||
>
|
||||
<!-- Grid items will be dynamically added here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Available Fields Sidebar -->
|
||||
<div class="w-80 border-l bg-white dark:bg-slate-950 p-4 overflow-auto">
|
||||
<h3 class="text-lg font-semibold mb-4">Available Fields</h3>
|
||||
<p class="text-xs text-muted-foreground mb-4">Click or drag to add field to grid</p>
|
||||
<div class="space-y-2" id="sidebar-fields">
|
||||
<div
|
||||
v-for="field in availableFields"
|
||||
:key="field.id"
|
||||
class="p-3 border rounded cursor-move bg-white dark:bg-slate-900 hover:border-primary hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
||||
:data-field-id="field.id"
|
||||
draggable="true"
|
||||
@dragstart="handleDragStart($event, field)"
|
||||
@click="addFieldToGrid(field)"
|
||||
>
|
||||
<div class="font-medium text-sm">{{ field.label }}</div>
|
||||
<div class="text-xs text-muted-foreground">{{ field.apiName }}</div>
|
||||
<div class="text-xs text-muted-foreground">Type: {{ field.type }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import { GridStack } from 'gridstack'
|
||||
import 'gridstack/dist/gridstack.min.css'
|
||||
import type { FieldLayoutItem } from '~/types/page-layout'
|
||||
import type { FieldConfig } from '~/types/field-types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<{
|
||||
fields: FieldConfig[]
|
||||
initialLayout?: FieldLayoutItem[]
|
||||
layoutName?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [layout: FieldLayoutItem[]]
|
||||
}>()
|
||||
|
||||
const gridContainer = ref<HTMLElement | null>(null)
|
||||
let grid: GridStack | null = null
|
||||
const gridItems = ref<Map<string, any>>(new Map())
|
||||
|
||||
// Fields that are already on the grid
|
||||
const placedFieldIds = ref<Set<string>>(new Set())
|
||||
|
||||
// Fields available to be added
|
||||
const availableFields = computed(() => {
|
||||
return props.fields.filter(field => !placedFieldIds.value.has(field.id))
|
||||
})
|
||||
|
||||
const initGrid = () => {
|
||||
if (!gridContainer.value) return
|
||||
|
||||
grid = GridStack.init({
|
||||
column: 6,
|
||||
cellHeight: 80,
|
||||
minRow: 10,
|
||||
float: true,
|
||||
animate: true,
|
||||
acceptWidgets: true,
|
||||
disableOneColumnMode: true,
|
||||
resizable: {
|
||||
handles: 'e, w'
|
||||
}
|
||||
}, gridContainer.value)
|
||||
|
||||
// Listen for changes
|
||||
grid.on('change', () => {
|
||||
updatePlacedFields()
|
||||
})
|
||||
|
||||
// Listen for item removal
|
||||
grid.on('removed', (event, items) => {
|
||||
items.forEach(item => {
|
||||
const contentEl = item.el?.querySelector('.grid-stack-item-content')
|
||||
const fieldId = contentEl?.getAttribute('data-field-id')
|
||||
if (fieldId) {
|
||||
placedFieldIds.value.delete(fieldId)
|
||||
gridItems.value.delete(fieldId)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Load initial layout if provided
|
||||
if (props.initialLayout && props.initialLayout.length > 0) {
|
||||
loadLayout(props.initialLayout)
|
||||
}
|
||||
}
|
||||
|
||||
const loadLayout = (layout: FieldLayoutItem[]) => {
|
||||
if (!grid) return
|
||||
|
||||
layout.forEach(item => {
|
||||
const field = props.fields.find(f => f.id === item.fieldId)
|
||||
if (field) {
|
||||
addFieldToGrid(field, item.x, item.y, item.w, item.h)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragStart = (event: DragEvent, field: FieldConfig) => {
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData('application/json', JSON.stringify(field));
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.dropEffect = 'copy';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const fieldData = event.dataTransfer?.getData('application/json');
|
||||
if (!fieldData || !grid) return;
|
||||
|
||||
const field = JSON.parse(fieldData);
|
||||
|
||||
// Get the grid bounding rect
|
||||
const gridRect = gridContainer.value?.getBoundingClientRect();
|
||||
if (!gridRect) return;
|
||||
|
||||
// Calculate grid position from drop coordinates
|
||||
const x = event.clientX - gridRect.left;
|
||||
const y = event.clientY - gridRect.top;
|
||||
|
||||
// Convert pixels to grid coordinates (approx)
|
||||
const cellWidth = gridRect.width / 6; // 6 columns
|
||||
const cellHeight = 80; // from our config
|
||||
|
||||
const gridX = Math.floor(x / cellWidth);
|
||||
const gridY = Math.floor(y / cellHeight);
|
||||
|
||||
// Add the field at the calculated position
|
||||
addFieldToGrid(field, gridX, gridY);
|
||||
};
|
||||
|
||||
const addFieldToGrid = (field: FieldConfig, x?: number, y?: number, w: number = 3, h: number = 1) => {
|
||||
if (!grid || placedFieldIds.value.has(field.id)) return
|
||||
|
||||
// Create the widget element manually
|
||||
const widgetEl = document.createElement('div')
|
||||
widgetEl.className = 'grid-stack-item'
|
||||
|
||||
const contentEl = document.createElement('div')
|
||||
contentEl.className = 'grid-stack-item-content bg-white dark:bg-slate-900 border rounded p-3 shadow-sm'
|
||||
contentEl.setAttribute('data-field-id', field.id)
|
||||
|
||||
contentEl.innerHTML = `
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="flex-1">
|
||||
<div class="font-medium text-sm">${field.label}</div>
|
||||
<div class="text-xs text-muted-foreground">${field.apiName}</div>
|
||||
<div class="text-xs text-muted-foreground">Type: ${field.type}</div>
|
||||
</div>
|
||||
<button class="remove-btn text-destructive hover:text-destructive/80 text-xl leading-none ml-2" type="button">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Add click handler for remove button
|
||||
const removeBtn = contentEl.querySelector('.remove-btn')
|
||||
if (removeBtn) {
|
||||
removeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
if (grid) {
|
||||
grid.removeWidget(widgetEl)
|
||||
placedFieldIds.value.delete(field.id)
|
||||
gridItems.value.delete(field.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
widgetEl.appendChild(contentEl)
|
||||
|
||||
// Use makeWidget for GridStack v11+
|
||||
grid.makeWidget(widgetEl)
|
||||
|
||||
// Set grid position after making it a widget
|
||||
grid.update(widgetEl, {
|
||||
x: x,
|
||||
y: y,
|
||||
w: w,
|
||||
h: h,
|
||||
minW: 1,
|
||||
maxW: 6,
|
||||
})
|
||||
|
||||
placedFieldIds.value.add(field.id)
|
||||
gridItems.value.set(field.id, widgetEl)
|
||||
}
|
||||
|
||||
const updatePlacedFields = () => {
|
||||
if (!grid) return
|
||||
|
||||
const items = grid.getGridItems()
|
||||
const newPlacedIds = new Set<string>()
|
||||
|
||||
items.forEach(item => {
|
||||
const contentEl = item.querySelector('.grid-stack-item-content')
|
||||
const fieldId = contentEl?.getAttribute('data-field-id')
|
||||
if (fieldId) {
|
||||
newPlacedIds.add(fieldId)
|
||||
}
|
||||
})
|
||||
|
||||
placedFieldIds.value = newPlacedIds
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
if (!grid) return
|
||||
|
||||
if (confirm('Are you sure you want to clear all fields from the layout?')) {
|
||||
grid.removeAll()
|
||||
placedFieldIds.value.clear()
|
||||
gridItems.value.clear()
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
if (!grid) return
|
||||
|
||||
const items = grid.getGridItems()
|
||||
const layout: FieldLayoutItem[] = []
|
||||
|
||||
items.forEach(item => {
|
||||
// Look for data-field-id in the content element
|
||||
const contentEl = item.querySelector('.grid-stack-item-content')
|
||||
const fieldId = contentEl?.getAttribute('data-field-id')
|
||||
const node = (item as any).gridstackNode
|
||||
|
||||
if (fieldId && node) {
|
||||
layout.push({
|
||||
fieldId,
|
||||
x: node.x,
|
||||
y: node.y,
|
||||
w: node.w,
|
||||
h: node.h,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
emit('save', layout)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initGrid()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (grid) {
|
||||
grid.destroy(false)
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for fields changes
|
||||
watch(() => props.fields, () => {
|
||||
updatePlacedFields()
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.grid-stack {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.grid-stack-item {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.grid-stack-item-content {
|
||||
cursor: move;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid-stack-item .remove-btn {
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
/* Customize grid appearance */
|
||||
.grid-stack > .grid-stack-item > .ui-resizable-se {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.grid-stack > .grid-stack-item > .ui-resizable-handle {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
.dark .grid-stack > .grid-stack-item > .ui-resizable-handle {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
</style>
|
||||
101
frontend/components/PageLayoutRenderer.vue
Normal file
101
frontend/components/PageLayoutRenderer.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<div class="page-layout-renderer w-full">
|
||||
<div
|
||||
v-if="layout && layout.fields.length > 0"
|
||||
class="grid grid-cols-6 gap-4 auto-rows-[80px]"
|
||||
>
|
||||
<div
|
||||
v-for="fieldItem in sortedFields"
|
||||
:key="fieldItem.fieldId"
|
||||
:style="getFieldStyle(fieldItem)"
|
||||
class="flex flex-col min-h-[60px]"
|
||||
>
|
||||
<FieldRenderer
|
||||
v-if="fieldItem.field"
|
||||
:field="fieldItem.field"
|
||||
:model-value="modelValue?.[fieldItem.field.apiName]"
|
||||
:mode="readonly ? VM.DETAIL : VM.EDIT"
|
||||
@update:model-value="handleFieldUpdate(fieldItem.field.apiName, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fallback: Simple two-column layout if no page layout is configured -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="field in fields"
|
||||
:key="field.id"
|
||||
class="flex flex-col min-h-[60px]"
|
||||
>
|
||||
<FieldRenderer
|
||||
:field="field"
|
||||
:model-value="modelValue?.[field.apiName]"
|
||||
:mode="readonly ? VM.DETAIL : VM.EDIT"
|
||||
@update:model-value="handleFieldUpdate(field.apiName, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import type { FieldConfig, ViewMode } from '~/types/field-types'
|
||||
import type { PageLayoutConfig, FieldLayoutItem } from '~/types/page-layout'
|
||||
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
|
||||
import { ViewMode as VM } from '~/types/field-types'
|
||||
|
||||
const props = defineProps<{
|
||||
fields: FieldConfig[]
|
||||
layout?: PageLayoutConfig | null
|
||||
modelValue?: Record<string, any>
|
||||
readonly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: Record<string, any>]
|
||||
}>()
|
||||
|
||||
// Map field IDs to field objects and sort by position
|
||||
const sortedFields = computed(() => {
|
||||
if (!props.layout || !props.layout.fields) return []
|
||||
|
||||
const fieldsMap = new Map(props.fields.map(f => [f.id, f]))
|
||||
|
||||
return props.layout.fields
|
||||
.map(item => ({
|
||||
...item,
|
||||
field: fieldsMap.get(item.fieldId),
|
||||
}))
|
||||
.filter(item => item.field)
|
||||
.sort((a, b) => {
|
||||
// Sort by y position first, then x position
|
||||
if (a.y !== b.y) return a.y - b.y
|
||||
return a.x - b.x
|
||||
})
|
||||
})
|
||||
|
||||
const getFieldStyle = (item: FieldLayoutItem) => {
|
||||
return {
|
||||
gridColumnStart: item.x + 1,
|
||||
gridColumnEnd: `span ${item.w}`,
|
||||
gridRowStart: item.y + 1,
|
||||
gridRowEnd: `span ${item.h}`,
|
||||
}
|
||||
}
|
||||
|
||||
const handleFieldUpdate = (fieldName: string, value: any) => {
|
||||
if (props.readonly) return
|
||||
|
||||
const updated = {
|
||||
...props.modelValue,
|
||||
[fieldName]: value,
|
||||
}
|
||||
|
||||
emit('update:modelValue', updated)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Additional styles if needed */
|
||||
</style>
|
||||
@@ -4,15 +4,20 @@ import { computed, type HTMLAttributes } from 'vue'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<TabsRootProps & { class?: HTMLAttributes['class'] }>()
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const delegatedProps = computed(() => {
|
||||
const { class: _, ...delegated } = props
|
||||
return delegated
|
||||
})
|
||||
|
||||
const handleUpdate = (value: string) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot v-bind="delegatedProps" :class="cn('', props.class)">
|
||||
<TabsRoot v-bind="delegatedProps" :class="cn('', props.class)" @update:model-value="handleUpdate">
|
||||
<slot />
|
||||
</TabsRoot>
|
||||
</template>
|
||||
|
||||
203
frontend/components/views/DetailViewEnhanced.vue
Normal file
203
frontend/components/views/DetailViewEnhanced.vue
Normal file
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, 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 { DetailViewConfig, ViewMode, FieldSection, FieldConfig } from '@/types/field-types'
|
||||
import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import type { PageLayoutConfig } from '~/types/page-layout'
|
||||
|
||||
interface Props {
|
||||
config: DetailViewConfig
|
||||
data: any
|
||||
loading?: boolean
|
||||
objectId?: string // For fetching page layout
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'edit': []
|
||||
'delete': []
|
||||
'back': []
|
||||
'action': [actionId: string]
|
||||
}>()
|
||||
|
||||
const { getDefaultPageLayout } = usePageLayouts()
|
||||
const pageLayout = ref<PageLayoutConfig | null>(null)
|
||||
const loadingLayout = ref(false)
|
||||
|
||||
// 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 (for traditional view)
|
||||
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.showOnDetail !== false)
|
||||
.map(f => f.apiName),
|
||||
}]
|
||||
})
|
||||
|
||||
const getFieldsBySection = (section: FieldSection) => {
|
||||
return section.fields
|
||||
.map(apiName => props.config.fields.find(f => f.apiName === apiName))
|
||||
.filter((field): field is FieldConfig => field !== undefined)
|
||||
}
|
||||
|
||||
// Use page layout if available, otherwise fall back to sections
|
||||
const usePageLayout = computed(() => {
|
||||
return pageLayout.value && pageLayout.value.fields && pageLayout.value.fields.length > 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="detail-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?.name || data?.title || config.objectApiName }}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Custom Actions -->
|
||||
<Button
|
||||
v-for="action in config.actions"
|
||||
:key="action.id"
|
||||
:variant="action.variant || 'outline'"
|
||||
size="sm"
|
||||
@click="emit('action', action.id)"
|
||||
>
|
||||
{{ action.label }}
|
||||
</Button>
|
||||
|
||||
<!-- Default Actions -->
|
||||
<Button variant="outline" size="sm" @click="emit('edit')">
|
||||
<Edit class="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" @click="emit('delete')">
|
||||
<Trash2 class="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</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>Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PageLayoutRenderer
|
||||
:fields="config.fields"
|
||||
:layout="pageLayout"
|
||||
:model-value="data"
|
||||
:readonly="true"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<!-- Traditional Section-based Layout -->
|
||||
<div v-else 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">
|
||||
<FieldRenderer
|
||||
v-for="field in getFieldsBySection(section)"
|
||||
:key="field.id"
|
||||
:field="field"
|
||||
:model-value="data[field.apiName]"
|
||||
:mode="ViewMode.DETAIL"
|
||||
/>
|
||||
</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">
|
||||
<FieldRenderer
|
||||
v-for="field in getFieldsBySection(section)"
|
||||
:key="field?.id"
|
||||
:field="field"
|
||||
:model-value="data[field.apiName]"
|
||||
:mode="ViewMode.DETAIL"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.detail-view-enhanced {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
303
frontend/components/views/EditViewEnhanced.vue
Normal file
303
frontend/components/views/EditViewEnhanced.vue
Normal file
@@ -0,0 +1,303 @@
|
||||
<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
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
data: () => ({}),
|
||||
loading: false,
|
||||
saving: false,
|
||||
})
|
||||
|
||||
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()) {
|
||||
emit('save', formData.value)
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
}
|
||||
</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]"
|
||||
:mode="ViewMode.EDIT"
|
||||
:error="errors[field.apiName]"
|
||||
@update:model-value="handleFieldUpdate(field.apiName, $event)"
|
||||
/>
|
||||
</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]"
|
||||
:mode="ViewMode.EDIT"
|
||||
:error="errors[field.apiName]"
|
||||
@update:model-value="handleFieldUpdate(field.apiName, $event)"
|
||||
/>
|
||||
</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>
|
||||
Reference in New Issue
Block a user