Added auth functionality, initial work with views and field types

This commit is contained in:
Francisco Gaona
2025-12-22 03:31:55 +01:00
parent 859dca6c84
commit 0fe56c0e03
170 changed files with 11599 additions and 435 deletions

View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
import { computed } 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 { DetailViewConfig, ViewMode, FieldSection } from '@/types/field-types'
import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
interface Props {
config: DetailViewConfig
data: any
loading?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
})
const emit = defineEmits<{
'edit': []
'delete': []
'back': []
'action': [actionId: string]
}>()
// 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.showOnDetail !== false)
.map(f => f.apiName),
}]
})
const getFieldsBySection = (section: FieldSection) => {
return section.fields
.map(apiName => props.config.fields.find(f => f.apiName === apiName))
.filter(Boolean)
}
</script>
<template>
<div class="detail-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?.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" 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 Sections -->
<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 {
width: 100%;
}
</style>

View 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>

View File

@@ -0,0 +1,234 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Checkbox } from '@/components/ui/checkbox'
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
import { ListViewConfig, ViewMode, FieldType } from '@/types/field-types'
import { ChevronDown, ChevronUp, Search, Plus, Download, Trash2, Edit } from 'lucide-vue-next'
interface Props {
config: ListViewConfig
data?: any[]
loading?: boolean
selectable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
data: () => [],
loading: false,
selectable: false,
})
const emit = defineEmits<{
'row-click': [row: any]
'row-select': [rows: any[]]
'create': []
'edit': [row: any]
'delete': [rows: any[]]
'action': [actionId: string, rows: any[]]
'sort': [field: string, direction: 'asc' | 'desc']
'search': [query: string]
'refresh': []
}>()
// State
const selectedRows = ref<Set<string>>(new Set())
const searchQuery = ref('')
const sortField = ref<string>('')
const sortDirection = ref<'asc' | 'desc'>('asc')
// Computed
const visibleFields = computed(() =>
props.config.fields.filter(f => f.showOnList !== false)
)
const allSelected = computed({
get: () => props.data.length > 0 && selectedRows.value.size === props.data.length,
set: (val: boolean) => {
if (val) {
selectedRows.value = new Set(props.data.map(row => row.id))
} else {
selectedRows.value.clear()
}
emit('row-select', getSelectedRows())
},
})
const getSelectedRows = () => {
return props.data.filter(row => selectedRows.value.has(row.id))
}
const toggleRowSelection = (rowId: string) => {
if (selectedRows.value.has(rowId)) {
selectedRows.value.delete(rowId)
} else {
selectedRows.value.add(rowId)
}
emit('row-select', getSelectedRows())
}
const handleSort = (field: string) => {
if (sortField.value === field) {
sortDirection.value = sortDirection.value === 'asc' ? 'desc' : 'asc'
} else {
sortField.value = field
sortDirection.value = 'asc'
}
emit('sort', field, sortDirection.value)
}
const handleSearch = () => {
emit('search', searchQuery.value)
}
const handleAction = (actionId: string) => {
emit('action', actionId, getSelectedRows())
}
</script>
<template>
<div class="list-view space-y-4">
<!-- Toolbar -->
<div class="flex items-center justify-between gap-4">
<!-- Search -->
<div v-if="config.searchable !== false" class="flex-1 max-w-sm">
<div class="relative">
<Search class="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
v-model="searchQuery"
placeholder="Search..."
class="pl-8"
@keyup.enter="handleSearch"
/>
</div>
</div>
<div class="flex items-center gap-2">
<!-- Bulk Actions -->
<template v-if="selectedRows.size > 0">
<Badge variant="secondary" class="px-3 py-1">
{{ selectedRows.size }} selected
</Badge>
<Button variant="outline" size="sm" @click="emit('delete', getSelectedRows())">
<Trash2 class="h-4 w-4 mr-2" />
Delete
</Button>
</template>
<!-- Custom Actions -->
<Button
v-for="action in config.actions"
:key="action.id"
:variant="action.variant || 'outline'"
size="sm"
@click="handleAction(action.id)"
>
{{ action.label }}
</Button>
<!-- Export -->
<Button v-if="config.exportable" variant="outline" size="sm">
<Download class="h-4 w-4 mr-2" />
Export
</Button>
<!-- Create -->
<Button size="sm" @click="emit('create')">
<Plus class="h-4 w-4 mr-2" />
New
</Button>
</div>
</div>
<!-- Table -->
<div class="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead v-if="selectable" class="w-12">
<Checkbox v-model:checked="allSelected" />
</TableHead>
<TableHead
v-for="field in visibleFields"
:key="field.id"
:class="{ 'cursor-pointer hover:bg-muted/50': field.sortable !== false }"
@click="field.sortable !== false && handleSort(field.apiName)"
>
<div class="flex items-center gap-2">
{{ field.label }}
<template v-if="field.sortable !== false && sortField === field.apiName">
<ChevronUp v-if="sortDirection === 'asc'" class="h-4 w-4" />
<ChevronDown v-else class="h-4 w-4" />
</template>
</div>
</TableHead>
<TableHead class="w-20">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-if="loading">
<TableCell :colspan="visibleFields.length + (selectable ? 2 : 1)" class="text-center py-8">
<div class="flex items-center justify-center">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
</TableCell>
</TableRow>
<TableRow v-else-if="data.length === 0">
<TableCell :colspan="visibleFields.length + (selectable ? 2 : 1)" class="text-center py-8 text-muted-foreground">
No records found
</TableCell>
</TableRow>
<TableRow
v-else
v-for="row in data"
:key="row.id"
class="cursor-pointer hover:bg-muted/50"
@click="emit('row-click', row)"
>
<TableCell v-if="selectable" @click.stop>
<Checkbox
:checked="selectedRows.has(row.id)"
@update:checked="toggleRowSelection(row.id)"
/>
</TableCell>
<TableCell v-for="field in visibleFields" :key="field.id">
<FieldRenderer
:field="field"
:model-value="row[field.apiName]"
:mode="ViewMode.LIST"
/>
</TableCell>
<TableCell @click.stop>
<div class="flex items-center gap-1">
<Button variant="ghost" size="sm" @click="emit('edit', row)">
<Edit class="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" @click="emit('delete', [row])">
<Trash2 class="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- Pagination would go here -->
</div>
</template>
<style scoped>
.list-view {
width: 100%;
}
</style>