Add record access strategy

This commit is contained in:
Francisco Gaona
2026-01-05 07:48:22 +01:00
parent 838a010fb2
commit 16907aadf8
97 changed files with 11350 additions and 208 deletions

View File

@@ -17,7 +17,7 @@ import {
SidebarRail,
} from '@/components/ui/sidebar'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { LayoutGrid, Boxes, Settings, Home, ChevronRight, Database, Layers, LogOut } from 'lucide-vue-next'
import { LayoutGrid, Boxes, Settings, Home, ChevronRight, Database, Layers, LogOut, Users, Globe, Building } from 'lucide-vue-next'
const { logout } = useAuth()
const { api } = useApi()
@@ -26,12 +26,31 @@ const handleLogout = async () => {
await logout()
}
// Check if user is central admin (by checking if we're on a central subdomain)
// Use ref instead of computed to avoid hydration mismatch
const isCentralAdmin = ref(false)
// Fetch objects and group by app
const apps = ref<any[]>([])
const topLevelObjects = ref<any[]>([])
const loading = ref(true)
onMounted(async () => {
// Set isCentralAdmin first
if (process.client) {
const hostname = window.location.hostname
const parts = hostname.split('.')
const subdomain = parts.length >= 2 ? parts[0] : null
const centralSubdomains = ['central', 'admin']
isCentralAdmin.value = subdomain ? centralSubdomains.includes(subdomain) : false
}
// Don't fetch tenant objects if we're on a central subdomain
if (isCentralAdmin.value) {
loading.value = false
return
}
try {
const response = await api.get('/setup/objects')
const allObjects = response.data || response || []
@@ -86,6 +105,49 @@ const staticMenuItems = [
url: '/setup/objects',
icon: Boxes,
},
{
title: 'Users',
url: '/setup/users',
icon: Users,
},
{
title: 'Roles',
url: '/setup/roles',
icon: Layers,
},
],
},
]
const centralAdminMenuItems: Array<{
title: string
icon: any
url?: string
items?: Array<{
title: string
url: string
icon: any
}>
}> = [
{
title: 'Central Admin',
icon: Settings,
items: [
{
title: 'Tenants',
url: '/central/tenants',
icon: Building,
},
{
title: 'Domains',
url: '/central/domains',
icon: Globe,
},
{
title: 'Admin Users',
url: '/central/users',
icon: Users,
},
],
},
]
@@ -160,6 +222,53 @@ const staticMenuItems = [
</SidebarGroupContent>
</SidebarGroup>
<!-- Central Admin Menu Items (only visible to central admins) -->
<SidebarGroup v-if="isCentralAdmin">
<SidebarGroupLabel>Central Administration</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
<template v-for="item in centralAdminMenuItems" :key="item.title">
<!-- Simple menu item -->
<SidebarMenuItem v-if="!item.items">
<SidebarMenuButton as-child>
<NuxtLink :to="item.url">
<component :is="item.icon" />
<span>{{ item.title }}</span>
</NuxtLink>
</SidebarMenuButton>
</SidebarMenuItem>
<!-- Collapsible menu item with submenu -->
<Collapsible v-else-if="item.items" as-child :default-open="true" class="group/collapsible">
<SidebarMenuItem>
<CollapsibleTrigger as-child>
<SidebarMenuButton :tooltip="item.title">
<component :is="item.icon" />
<span>{{ item.title }}</span>
<ChevronRight
class="ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90"
/>
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
<SidebarMenuSubItem v-for="subItem in item.items" :key="subItem.title">
<SidebarMenuSubButton as-child>
<NuxtLink :to="subItem.url">
<component v-if="subItem.icon" :is="subItem.icon" />
<span>{{ subItem.title }}</span>
</NuxtLink>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
</template>
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<!-- Top-level Objects (no app) -->
<SidebarGroup v-if="!loading && topLevelObjects.length > 0">
<SidebarGroupLabel>Objects</SidebarGroupLabel>

View File

@@ -0,0 +1,344 @@
<template>
<Card>
<CardHeader>
<CardTitle>Field-Level Security</CardTitle>
<CardDescription>
Control which fields each role can read and edit
</CardDescription>
</CardHeader>
<CardContent>
<div v-if="loading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<div v-else-if="roles.length === 0" class="text-sm text-muted-foreground py-4">
No roles available. Create roles first to manage field-level permissions.
</div>
<div v-else class="space-y-6">
<!-- Role Selector -->
<div class="space-y-2">
<Label>Select Role</Label>
<Select v-model="selectedRoleId" @update:model-value="(value) => selectedRoleId = value">
<SelectTrigger class="w-full">
<SelectValue placeholder="Choose a role to configure permissions" />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="role in roles" :key="role.id" :value="role.id">
{{ role.name }}
</SelectItem>
</SelectContent>
</Select>
</div>
<!-- Object-Level Permissions -->
<div v-if="selectedRoleId" class="space-y-2">
<h3 class="text-sm font-medium">Object-Level Permissions</h3>
<div class="rounded-md border">
<table class="w-full">
<thead>
<tr class="border-b bg-muted/50">
<th class="p-3 text-left font-medium">Permission</th>
<th class="p-3 text-center font-medium">Enabled</th>
</tr>
</thead>
<tbody>
<tr class="border-b hover:bg-muted/30">
<td class="p-3">Create</td>
<td class="p-3 text-center">
<Checkbox
:model-value="objectPermissions.canCreate"
@update:model-value="(checked: boolean) => updateObjectPermission('canCreate', checked)"
/>
</td>
</tr>
<tr class="border-b hover:bg-muted/30">
<td class="p-3">Read</td>
<td class="p-3 text-center">
<Checkbox
:model-value="objectPermissions.canRead"
@update:model-value="(checked: boolean) => updateObjectPermission('canRead', checked)"
/>
</td>
</tr>
<tr class="border-b hover:bg-muted/30">
<td class="p-3">Edit</td>
<td class="p-3 text-center">
<Checkbox
:model-value="objectPermissions.canEdit"
@update:model-value="(checked: boolean) => updateObjectPermission('canEdit', checked)"
/>
</td>
</tr>
<tr class="border-b hover:bg-muted/30">
<td class="p-3">Delete</td>
<td class="p-3 text-center">
<Checkbox
:model-value="objectPermissions.canDelete"
@update:model-value="(checked: boolean) => updateObjectPermission('canDelete', checked)"
/>
</td>
</tr>
<tr class="border-b hover:bg-muted/30">
<td class="p-3">View All</td>
<td class="p-3 text-center">
<Checkbox
:model-value="objectPermissions.canViewAll"
@update:model-value="(checked: boolean) => updateObjectPermission('canViewAll', checked)"
/>
</td>
</tr>
<tr class="hover:bg-muted/30">
<td class="p-3">Modify All</td>
<td class="p-3 text-center">
<Checkbox
:model-value="objectPermissions.canModifyAll"
@update:model-value="(checked: boolean) => updateObjectPermission('canModifyAll', checked)"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Field-Level Permissions -->
<div v-if="selectedRoleId" class="space-y-2">
<h3 class="text-sm font-medium">Field-Level Permissions</h3>
<div class="rounded-md border">
<table class="w-full">
<thead>
<tr class="border-b bg-muted/50">
<th class="p-3 text-left font-medium">Field</th>
<th class="p-3 text-center font-medium">Read</th>
<th class="p-3 text-center font-medium">Edit</th>
</tr>
</thead>
<tbody>
<tr
v-for="field in fields"
:key="field.id"
class="border-b hover:bg-muted/30"
>
<td class="p-3">
<div>
<div class="font-medium">{{ field.label }}</div>
<div class="text-xs text-muted-foreground">{{ field.apiName }}</div>
</div>
</td>
<td class="p-3 text-center">
<Checkbox
:model-value="hasPermission(field.id, selectedRoleId, 'read')"
@update:model-value="(checked: boolean) => updatePermission(field.id, selectedRoleId, 'read', checked)"
:disabled="field.isSystem"
/>
</td>
<td class="p-3 text-center">
<Checkbox
:model-value="hasPermission(field.id, selectedRoleId, 'edit')"
@update:model-value="(checked: boolean) => updatePermission(field.id, selectedRoleId, 'edit', checked)"
:disabled="field.isSystem || !hasPermission(field.id, selectedRoleId, 'read')"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Info class="h-4 w-4" />
<span>System fields are always readable. Edit permissions require read permission first. Changes save automatically.</span>
</div>
<div v-if="saving" class="flex items-center gap-2 text-sm text-primary">
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
<span>Saving...</span>
</div>
</div>
</CardContent>
</Card>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card';
import { Checkbox } from '~/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select';
import { Label } from '~/components/ui/label';
import { Info } from 'lucide-vue-next';
const props = defineProps<{
objectId: string;
objectApiName: string;
fields: any[];
}>();
const { api } = useApi();
const { toast } = useToast();
const loading = ref(true);
const saving = ref(false);
const roles = ref<any[]>([]);
const selectedRoleId = ref<string>('');
const permissions = ref<Map<string, Map<string, { canRead: boolean; canEdit: boolean }>>>(new Map());
const objectPermissions = ref({
canCreate: false,
canRead: false,
canEdit: false,
canDelete: false,
canViewAll: false,
canModifyAll: false,
});
// Load roles and permissions
onMounted(async () => {
try {
loading.value = true;
// Load roles
const rolesResponse = await api.get('/setup/roles');
roles.value = rolesResponse || [];
// Load existing permissions for this object
const permsResponse = await api.get(`/setup/objects/${props.objectId}/field-permissions`);
// Build permissions map: fieldId -> roleId -> {canRead, canEdit}
const permsMap = new Map();
if (permsResponse && Array.isArray(permsResponse)) {
for (const perm of permsResponse) {
if (!permsMap.has(perm.fieldDefinitionId)) {
permsMap.set(perm.fieldDefinitionId, new Map());
}
permsMap.get(perm.fieldDefinitionId).set(perm.roleId, {
canRead: Boolean(perm.canRead),
canEdit: Boolean(perm.canEdit),
});
}
}
permissions.value = permsMap;
} catch (error: any) {
console.error('Failed to load field permissions:', error);
toast.error('Failed to load field permissions');
} finally {
loading.value = false;
}
});
const hasPermission = (fieldId: string, roleId: string, type: 'read' | 'edit'): boolean => {
const fieldPerms = permissions.value.get(fieldId);
if (!fieldPerms) return true; // Default to true if no permissions set
const rolePerm = fieldPerms.get(roleId);
if (!rolePerm) return true; // Default to true if no permissions set
const value = type === 'read' ? rolePerm.canRead : rolePerm.canEdit;
return Boolean(value); // Convert 1/0 to true/false
};
const updatePermission = async (fieldId: string, roleId: string, type: 'read' | 'edit', checked: boolean) => {
try {
saving.value = true;
// Get current permissions
if (!permissions.value.has(fieldId)) {
permissions.value.set(fieldId, new Map());
}
const fieldPerms = permissions.value.get(fieldId)!;
if (!fieldPerms.has(roleId)) {
fieldPerms.set(roleId, { canRead: true, canEdit: true });
}
const perm = fieldPerms.get(roleId)!;
// Update permission
if (type === 'read') {
perm.canRead = checked;
// If disabling read, also disable edit
if (!checked) {
perm.canEdit = false;
}
} else {
perm.canEdit = checked;
// If enabling edit, also enable read
if (checked) {
perm.canRead = true;
}
}
// Save to backend
await api.put(`/setup/objects/${props.objectId}/field-permissions`, {
roleId,
fieldDefinitionId: fieldId,
canRead: perm.canRead,
canEdit: perm.canEdit,
});
toast.success('Permission updated');
} catch (error: any) {
console.error('Failed to update field permission:', error);
toast.error(error.message || 'Failed to update permission');
// Revert change
if (!permissions.value.has(fieldId)) return;
const fieldPerms = permissions.value.get(fieldId)!;
if (!fieldPerms.has(roleId)) return;
const perm = fieldPerms.get(roleId)!;
if (type === 'read') {
perm.canRead = !checked;
} else {
perm.canEdit = !checked;
}
} finally {
saving.value = false;
}
};
const updateObjectPermission = async (permission: string, checked: boolean) => {
if (!selectedRoleId.value) return;
try {
saving.value = true;
// Update local state
(objectPermissions.value as any)[permission] = checked;
// Save to backend
await api.put(`/setup/objects/${props.objectApiName}/permissions`, {
roleId: selectedRoleId.value,
...objectPermissions.value,
});
toast.success('Object permission updated');
} catch (error: any) {
console.error('Failed to update object permission:', error);
toast.error(error.message || 'Failed to update permission');
// Revert change
(objectPermissions.value as any)[permission] = !checked;
} finally {
saving.value = false;
}
};
// Load object permissions when role changes
watch(selectedRoleId, async (roleId) => {
if (!roleId) return;
try {
const response = await api.get(`/setup/objects/${props.objectApiName}/permissions/${roleId}`);
if (response) {
objectPermissions.value = {
canCreate: Boolean(response.canCreate),
canRead: Boolean(response.canRead),
canEdit: Boolean(response.canEdit),
canDelete: Boolean(response.canDelete),
canViewAll: Boolean(response.canViewAll),
canModifyAll: Boolean(response.canModifyAll),
};
}
} catch (error: any) {
console.error('Failed to load object permissions:', error);
}
});
</script>

View File

@@ -0,0 +1,119 @@
<template>
<div class="space-y-6">
<Card>
<CardHeader>
<CardTitle>Org-Wide Default</CardTitle>
<CardDescription>
Control the baseline visibility for records of this object
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="orgWideDefault">Record Visibility</Label>
<Select v-model="localOrgWideDefault" @update:model-value="handleOrgWideDefaultChange">
<SelectTrigger id="orgWideDefault">
<SelectValue placeholder="Select visibility level" />
</SelectTrigger>
<SelectContent>
<SelectItem value="private">
<div>
<div class="font-semibold">Private</div>
<div class="text-xs text-muted-foreground">Only record owner can see</div>
</div>
</SelectItem>
<SelectItem value="public_read">
<div>
<div class="font-semibold">Public Read Only</div>
<div class="text-xs text-muted-foreground">Everyone can read, only owner can edit/delete</div>
</div>
</SelectItem>
<SelectItem value="public_read_write">
<div>
<div class="font-semibold">Public Read/Write</div>
<div class="text-xs text-muted-foreground">Everyone can read, edit, and delete all records</div>
</div>
</SelectItem>
</SelectContent>
</Select>
<p class="text-sm text-muted-foreground">
This setting controls who can see records by default. Individual user permissions are granted through roles.
</p>
</div>
</CardContent>
</Card>
<FieldLevelSecurity
v-if="objectId && objectApiName && fields && fields.length > 0"
:object-id="objectId"
:object-api-name="objectApiName"
:fields="fields"
/>
<div v-else-if="!objectId" class="text-sm text-muted-foreground">
Object ID not available
</div>
<div v-else-if="!fields || fields.length === 0" class="text-sm text-muted-foreground">
No fields available
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '~/components/ui/card';
import { Label } from '~/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select';
import FieldLevelSecurity from '~/components/FieldLevelSecurity.vue';
const props = defineProps<{
objectApiName: string;
objectId?: string;
orgWideDefault?: string;
fields?: any[];
}>();
const emit = defineEmits<{
update: [orgWideDefault: string];
}>();
const { $api } = useNuxtApp();
const { showToast } = useToast();
const localOrgWideDefault = ref(props.orgWideDefault || 'private');
// Watch for prop changes
watch(() => props.orgWideDefault, (newValue) => {
if (newValue) {
localOrgWideDefault.value = newValue;
}
});
const handleOrgWideDefaultChange = async (value: string) => {
try {
// Update object definition
await $api(`/api/setup/objects/${props.objectApiName}`, {
method: 'PATCH',
body: {
orgWideDefault: value
}
});
showToast({
title: 'Success',
description: 'Org-Wide Default saved successfully',
variant: 'default'
});
emit('update', value);
} catch (error: any) {
console.error('Failed to update org-wide default:', error);
showToast({
title: 'Error',
description: error.data?.message || 'Failed to save changes',
variant: 'destructive'
});
}
};
</script>

View File

@@ -14,6 +14,7 @@
v-if="fieldItem.field"
:field="fieldItem.field"
:model-value="modelValue?.[fieldItem.field.apiName]"
:record-data="modelValue"
:mode="readonly ? VM.DETAIL : VM.EDIT"
@update:model-value="handleFieldUpdate(fieldItem.field.apiName, $event)"
/>
@@ -30,6 +31,7 @@
<FieldRenderer
:field="field"
:model-value="modelValue?.[field.apiName]"
:record-data="modelValue"
:mode="readonly ? VM.DETAIL : VM.EDIT"
@update:model-value="handleFieldUpdate(field.apiName, $event)"
/>

View File

@@ -0,0 +1,348 @@
<template>
<div class="record-sharing space-y-4">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold">Sharing</h3>
<p class="text-sm text-muted-foreground">
Grant access to specific users for this record
</p>
</div>
<Button @click="showShareDialog = true" size="sm">
<UserPlus class="h-4 w-4 mr-2" />
Share
</Button>
</div>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-sm text-destructive">
{{ error }}
</div>
<!-- Shares List -->
<div v-else-if="shares.length > 0" class="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
<TableHead>Access</TableHead>
<TableHead>Shared</TableHead>
<TableHead class="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="share in shares" :key="share.id">
<TableCell class="font-medium">
{{ getUserName(share.granteeUser) }}
</TableCell>
<TableCell>{{ share.granteeUser.email }}</TableCell>
<TableCell>
<div class="flex gap-1">
<Badge v-if="share.accessLevel.canRead" variant="secondary">Read</Badge>
<Badge v-if="share.accessLevel.canEdit" variant="secondary">Edit</Badge>
<Badge v-if="share.accessLevel.canDelete" variant="secondary">Delete</Badge>
</div>
</TableCell>
<TableCell>{{ formatDate(share.createdAt) }}</TableCell>
<TableCell class="text-right">
<Button
variant="ghost"
size="icon"
@click="removeShare(share.id)"
:disabled="removing === share.id"
>
<Trash2 class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
<!-- Empty State -->
<div v-else class="text-center py-8 text-muted-foreground border rounded-lg">
<Users class="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>This record is not shared with anyone</p>
<p class="text-sm">Click "Share" to grant access to other users</p>
</div>
<!-- Share Dialog -->
<Dialog v-model:open="showShareDialog">
<DialogContent>
<DialogHeader>
<DialogTitle>Share Record</DialogTitle>
<DialogDescription>
Grant access to this record to specific users
</DialogDescription>
</DialogHeader>
<div class="space-y-4">
<div class="space-y-2">
<Label for="user">User</Label>
<Select v-model="newShare.userId" @update:model-value="(value) => newShare.userId = value">
<SelectTrigger>
<SelectValue placeholder="Select user" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="user in availableUsers"
:key="user.id"
:value="user.id"
>
{{ getUserName(user) }} ({{ user.email }})
</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-3">
<Label>Permissions</Label>
<div class="space-y-2">
<div class="flex items-center space-x-2">
<Checkbox
id="canRead"
v-model:checked="newShare.canRead"
@update:checked="(value) => newShare.canRead = value"
/>
<label
for="canRead"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Can Read
</label>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="canEdit"
v-model:checked="newShare.canEdit"
@update:checked="(value) => newShare.canEdit = value"
/>
<label
for="canEdit"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Can Edit
</label>
</div>
<div class="flex items-center space-x-2">
<Checkbox
id="canDelete"
v-model:checked="newShare.canDelete"
@update:checked="(value) => newShare.canDelete = value"
/>
<label
for="canDelete"
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
Can Delete
</label>
</div>
</div>
</div>
<div class="space-y-2">
<Label for="expiresAt">Expires At (Optional)</Label>
<div class="flex gap-2">
<DatePicker
v-model="expiresDate"
placeholder="Select date"
class="flex-1"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="showShareDialog = false">Cancel</Button>
<Button
@click="createShare"
:disabled="!newShare.userId || (!newShare.canRead && !newShare.canEdit && !newShare.canDelete) || sharing"
>
{{ sharing ? 'Sharing...' : 'Share' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { Button } from '~/components/ui/button';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '~/components/ui/table';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '~/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '~/components/ui/select';
import { Input } from '~/components/ui/input';
import { Label } from '~/components/ui/label';
import { Badge } from '~/components/ui/badge';
import Checkbox from '~/components/ui/checkbox.vue';
import DatePicker from '~/components/ui/date-picker/DatePicker.vue';
import { UserPlus, Trash2, Users } from 'lucide-vue-next';
interface Props {
objectApiName: string;
recordId: string;
ownerId?: string;
}
const props = defineProps<Props>();
const { api } = useApi();
const { toast } = useToast();
const loading = ref(true);
const sharing = ref(false);
const removing = ref<string | null>(null);
const error = ref<string | null>(null);
const shares = ref<any[]>([]);
const allUsers = ref<any[]>([]);
const showShareDialog = ref(false);
const newShare = ref({
userId: '',
canRead: true,
canEdit: false,
canDelete: false,
expiresAt: '',
});
const expiresDate = ref<Date | null>(null);
const expiresTime = ref('');
// Computed property to combine date and time into ISO string
const combinedExpiresAt = computed(() => {
if (!expiresDate.value) return '';
const date = new Date(expiresDate.value);
if (expiresTime.value) {
const [hours, minutes] = expiresTime.value.split(':');
date.setHours(parseInt(hours), parseInt(minutes), 0, 0);
} else {
date.setHours(23, 59, 59, 999); // Default to end of day
}
return date.toISOString();
});
// Filter out users who already have shares
const availableUsers = computed(() => {
const sharedUserIds = new Set(shares.value.map(s => s.granteeUserId));
return allUsers.value.filter(u => !sharedUserIds.has(u.id));
});
const loadShares = async () => {
try {
loading.value = true;
error.value = null;
const response = await api.get(
`/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares`
);
shares.value = response || [];
} catch (e: any) {
console.error('Failed to load shares:', e);
error.value = e.message || 'Failed to load shares';
// If user is not owner, they can't see shares
if (e.message?.includes('owner')) {
error.value = 'Only the record owner can manage sharing';
}
} finally {
loading.value = false;
}
};
const loadUsers = async () => {
try {
const response = await api.get('/setup/users');
allUsers.value = response || [];
} catch (e: any) {
console.error('Failed to load users:', e);
}
};
const createShare = async () => {
try {
sharing.value = true;
const expiresAtValue = combinedExpiresAt.value;
console.log('Creating share, expiresAt value:', expiresAtValue);
const payload: any = {
granteeUserId: newShare.value.userId,
canRead: newShare.value.canRead,
canEdit: newShare.value.canEdit,
canDelete: newShare.value.canDelete,
};
// Only include expiresAt if it has a value
if (expiresAtValue) {
payload.expiresAt = expiresAtValue;
console.log('Including expiresAt in payload:', payload.expiresAt);
} else {
console.log('Skipping expiresAt - no date selected');
}
console.log('Final payload:', payload);
await api.post(
`/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares`,
payload
);
toast.success('Record shared successfully');
showShareDialog.value = false;
newShare.value = {
userId: '',
canRead: true,
canEdit: false,
canDelete: false,
expiresAt: '',
};
expiresDate.value = null;
expiresTime.value = '';
await loadShares();
} catch (e: any) {
console.error('Failed to share record:', e);
toast.error(e.message || 'Failed to share record');
} finally {
sharing.value = false;
}
};
const removeShare = async (shareId: string) => {
try {
removing.value = shareId;
await api.delete(
`/runtime/objects/${props.objectApiName}/records/${props.recordId}/shares/${shareId}`
);
toast.success('Share removed successfully');
await loadShares();
} catch (e: any) {
console.error('Failed to remove share:', e);
toast.error(e.message || 'Failed to remove share');
} finally {
removing.value = null;
}
};
const getUserName = (user: any) => {
if (!user) return 'Unknown';
if (user.firstName || user.lastName) {
return [user.firstName, user.lastName].filter(Boolean).join(' ');
}
return user.email;
};
const formatDate = (date: string) => {
if (!date) return 'N/A';
return new Date(date).toLocaleDateString();
};
onMounted(async () => {
await Promise.all([loadShares(), loadUsers()]);
});
definePageMeta({
layout: 'default',
});
</script>

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Plus, ExternalLink } from 'lucide-vue-next'
import type { FieldConfig } from '@/types/field-types'
interface RelatedListConfig {
title: string
relationName: string // e.g., 'domains', 'users'
objectApiName: string // e.g., 'domains', 'users'
fields: FieldConfig[] // Fields to display in the list
canCreate?: boolean
createRoute?: string // Route to create new related record
}
interface Props {
config: RelatedListConfig
parentId: string
relatedRecords?: any[] // Can be passed in if already fetched
baseUrl?: string // Base API URL, defaults to '/central'
}
const props = withDefaults(defineProps<Props>(), {
baseUrl: '/central',
relatedRecords: undefined,
})
const emit = defineEmits<{
'navigate': [objectApiName: string, recordId: string]
'create': [objectApiName: string, parentId: string]
}>()
const { api } = useApi()
const records = ref<any[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
// Use provided records or fetch them
const displayRecords = computed(() => {
return props.relatedRecords || records.value
})
const fetchRelatedRecords = async () => {
if (props.relatedRecords) {
// Records already provided, no need to fetch
return
}
loading.value = true
error.value = null
try {
// Replace :parentId placeholder in the API path
let apiPath = props.config.objectApiName.replace(':parentId', props.parentId)
const response = await api.get(`${props.baseUrl}/${apiPath}`, {
params: {
parentId: props.parentId,
},
})
records.value = response || []
} catch (err: any) {
console.error('Error fetching related records:', err)
error.value = err.message || 'Failed to fetch related records'
} finally {
loading.value = false
}
}
const handleCreateNew = () => {
emit('create', props.config.objectApiName, props.parentId)
}
const handleViewRecord = (recordId: string) => {
emit('navigate', props.config.objectApiName, recordId)
}
const formatValue = (value: any, field: FieldConfig): string => {
if (value === null || value === undefined) return '-'
// Handle different field types
if (field.type === 'date') {
return new Date(value).toLocaleDateString()
}
if (field.type === 'datetime') {
return new Date(value).toLocaleString()
}
if (field.type === 'boolean') {
return value ? 'Yes' : 'No'
}
if (field.type === 'select' && field.options) {
const option = field.options.find(opt => opt.value === value)
return option?.label || value
}
return String(value)
}
onMounted(() => {
fetchRelatedRecords()
})
</script>
<template>
<Card class="related-list">
<CardHeader>
<div class="flex items-center justify-between">
<div>
<CardTitle>{{ config.title }}</CardTitle>
<CardDescription v-if="displayRecords.length > 0">
{{ displayRecords.length }} {{ displayRecords.length === 1 ? 'record' : 'records' }}
</CardDescription>
</div>
<Button
v-if="config.canCreate !== false"
size="sm"
@click="handleCreateNew"
>
<Plus class="h-4 w-4 mr-2" />
New
</Button>
</div>
</CardHeader>
<CardContent>
<!-- Loading State -->
<div v-if="loading" class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
</div>
<!-- Error State -->
<div v-else-if="error" class="text-sm text-destructive py-4">
{{ error }}
</div>
<!-- Empty State -->
<div v-else-if="displayRecords.length === 0" class="text-center py-8 text-muted-foreground">
<p class="text-sm">No {{ config.title.toLowerCase() }} yet</p>
<Button
v-if="config.canCreate !== false"
variant="outline"
size="sm"
class="mt-4"
@click="handleCreateNew"
>
<Plus class="h-4 w-4 mr-2" />
Create First {{ config.title.slice(0, -1) }}
</Button>
</div>
<!-- Records Table -->
<div v-else class="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead v-for="field in config.fields" :key="field.id">
{{ field.label }}
</TableHead>
<TableHead class="w-[80px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="record in displayRecords" :key="record.id">
<TableCell v-for="field in config.fields" :key="field.id">
{{ formatValue(record[field.apiName], field) }}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
@click="handleViewRecord(record.id)"
>
<ExternalLink class="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</template>
<style scoped>
.related-list {
margin-top: 1.5rem;
}
</style>

View File

@@ -0,0 +1,136 @@
<script setup lang="ts">
import { ref } from 'vue'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
interface Props {
open: boolean
tenantId: string
tenantName?: string
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:open': [value: boolean]
'created': [user: any]
}>()
const { api } = useApi()
const { toast } = useToast()
const formData = ref({
email: '',
password: '',
firstName: '',
lastName: '',
})
const saving = ref(false)
const handleSubmit = async () => {
if (!formData.value.email || !formData.value.password) {
toast.error('Email and password are required')
return
}
saving.value = true
try {
const response = await api.post(`/central/tenants/${props.tenantId}/users`, formData.value)
toast.success('User created successfully')
emit('created', response)
emit('update:open', false)
// Reset form
formData.value = {
email: '',
password: '',
firstName: '',
lastName: '',
}
} catch (error: any) {
console.error('Error creating user:', error)
toast.error(error.message || 'Failed to create user')
} finally {
saving.value = false
}
}
const handleCancel = () => {
emit('update:open', false)
// Reset form
formData.value = {
email: '',
password: '',
firstName: '',
lastName: '',
}
}
</script>
<template>
<Dialog :open="open" @update:open="(val) => emit('update:open', val)">
<DialogContent class="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Create Tenant User</DialogTitle>
<DialogDescription v-if="tenantName">
Add a new user to {{ tenantName }}
</DialogDescription>
</DialogHeader>
<div class="grid gap-4 py-4">
<div class="grid gap-2">
<Label for="email">Email *</Label>
<Input
id="email"
v-model="formData.email"
type="email"
placeholder="user@example.com"
required
/>
</div>
<div class="grid gap-2">
<Label for="password">Password *</Label>
<Input
id="password"
v-model="formData.password"
type="password"
placeholder="Enter password"
required
/>
</div>
<div class="grid gap-2">
<Label for="firstName">First Name</Label>
<Input
id="firstName"
v-model="formData.firstName"
type="text"
placeholder="John"
/>
</div>
<div class="grid gap-2">
<Label for="lastName">Last Name</Label>
<Input
id="lastName"
v-model="formData.lastName"
type="text"
placeholder="Doe"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" @click="handleCancel" :disabled="saving">
Cancel
</Button>
<Button @click="handleSubmit" :disabled="saving">
{{ saving ? 'Creating...' : 'Create User' }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref, watch, onMounted } from 'vue'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
@@ -9,19 +9,27 @@ import { DatePicker } from '@/components/ui/date-picker'
import { Badge } from '@/components/ui/badge'
import { FieldConfig, FieldType, ViewMode } from '@/types/field-types'
import { Label } from '@/components/ui/label'
import LookupField from '@/components/fields/LookupField.vue'
interface Props {
field: FieldConfig
modelValue: any
mode: ViewMode
readonly?: boolean
baseUrl?: string // Base URL for API calls
recordData?: any // Full record data to access related objects
}
const props = defineProps<Props>()
const props = withDefaults(defineProps<Props>(), {
baseUrl: '/central',
})
const emit = defineEmits<{
'update:modelValue': [value: any]
}>()
const { api } = useApi()
const value = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
@@ -32,10 +40,44 @@ const isEditMode = computed(() => props.mode === ViewMode.EDIT)
const isListMode = computed(() => props.mode === ViewMode.LIST)
const isDetailMode = computed(() => props.mode === ViewMode.DETAIL)
// Check if field is a relationship field
const isRelationshipField = computed(() => {
return [FieldType.BELONGS_TO].includes(props.field.type)
})
// Get relation object name from field apiName (e.g., 'ownerId' -> 'owner')
const getRelationPropertyName = () => {
// Backend attaches related object using field apiName without 'Id' suffix, lowercase
// e.g., ownerId -> owner, accountId -> account
return props.field.apiName.replace(/Id$/, '').toLowerCase()
}
// Display value for relationship fields
const relationshipDisplayValue = computed(() => {
if (!isRelationshipField.value) return props.modelValue || '-'
// First, check if the parent record data includes the related object
// This happens when backend uses .withGraphFetched()
if (props.recordData) {
const relationPropertyName = getRelationPropertyName()
const relatedObject = props.recordData[relationPropertyName]
if (relatedObject && typeof relatedObject === 'object') {
const displayField = props.field.relationDisplayField || 'name'
return relatedObject[displayField] || relatedObject.id || props.modelValue
}
}
// If no related object found in recordData, just show the ID
// (The fetch mechanism is removed to avoid N+1 queries)
return props.modelValue || '-'
})
const formatValue = (val: any): string => {
if (val === null || val === undefined) return '-'
switch (props.field.type) {
case FieldType.BELONGS_TO:
return relationshipDisplayValue.value
case FieldType.DATE:
return val instanceof Date ? val.toLocaleDateString() : new Date(val).toLocaleDateString()
case FieldType.DATETIME:
@@ -78,6 +120,7 @@ const formatValue = (val: any): string => {
{{ formatValue(value) }}
</Badge>
<template v-else>
{{ formatValue(value) }}
</template>
</div>
@@ -113,9 +156,17 @@ const formatValue = (val: any): string => {
<!-- Edit View - Input components -->
<div v-else-if="isEditMode && !isReadOnly">
<!-- Relationship Field - Lookup -->
<LookupField
v-if="field.type === FieldType.BELONGS_TO"
:field="field"
v-model="value"
:base-url="baseUrl"
/>
<!-- Text Input -->
<Input
v-if="[FieldType.TEXT, FieldType.EMAIL, FieldType.URL, FieldType.PASSWORD].includes(field.type)"
v-else-if="[FieldType.TEXT, FieldType.EMAIL, FieldType.URL, FieldType.PASSWORD].includes(field.type)"
:id="field.id"
v-model="value"
:type="field.type === FieldType.PASSWORD ? 'password' : field.type === FieldType.EMAIL ? 'email' : field.type === FieldType.URL ? 'url' : 'text'"

View File

@@ -0,0 +1,171 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Check, ChevronsUpDown, X } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import type { FieldConfig } from '@/types/field-types'
interface Props {
field: FieldConfig
modelValue: string | null // The ID of the selected record
readonly?: boolean
baseUrl?: string // Base API URL, defaults to '/central'
}
const props = withDefaults(defineProps<Props>(), {
baseUrl: '/central',
modelValue: null,
})
const emit = defineEmits<{
'update:modelValue': [value: string | null]
}>()
const { api } = useApi()
const open = ref(false)
const searchQuery = ref('')
const records = ref<any[]>([])
const loading = ref(false)
const selectedRecord = ref<any | null>(null)
// Get the relation configuration
const relationObject = computed(() => props.field.relationObject || props.field.apiName.replace('Id', ''))
const displayField = computed(() => props.field.relationDisplayField || 'name')
// Display value for the selected record
const displayValue = computed(() => {
if (!selectedRecord.value) return 'Select...'
return selectedRecord.value[displayField.value] || selectedRecord.value.id
})
// Filtered records based on search
const filteredRecords = computed(() => {
if (!searchQuery.value) return records.value
const query = searchQuery.value.toLowerCase()
return records.value.filter(record => {
const displayValue = record[displayField.value] || record.id
return displayValue.toLowerCase().includes(query)
})
})
// Fetch available records for the lookup
const fetchRecords = async () => {
loading.value = true
try {
const endpoint = `${props.baseUrl}/${relationObject.value}/records`
const response = await api.get(endpoint)
records.value = response || []
// If we have a modelValue, find the selected record
if (props.modelValue) {
selectedRecord.value = records.value.find(r => r.id === props.modelValue) || null
}
} catch (err) {
console.error('Error fetching lookup records:', err)
} finally {
loading.value = false
}
}
// Handle record selection
const selectRecord = (record: any) => {
selectedRecord.value = record
emit('update:modelValue', record.id)
open.value = false
}
// Clear selection
const clearSelection = () => {
selectedRecord.value = null
emit('update:modelValue', null)
}
// Watch for external modelValue changes
watch(() => props.modelValue, (newValue) => {
if (newValue && records.value.length > 0) {
selectedRecord.value = records.value.find(r => r.id === newValue) || null
} else if (!newValue) {
selectedRecord.value = null
}
})
onMounted(() => {
fetchRecords()
})
</script>
<template>
<div class="lookup-field space-y-2">
<Popover v-model:open="open">
<div class="flex gap-2">
<PopoverTrigger as-child>
<Button
variant="outline"
role="combobox"
:aria-expanded="open"
:disabled="readonly || loading"
class="flex-1 justify-between"
>
<span class="truncate">{{ displayValue }}</span>
<ChevronsUpDown class="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<Button
v-if="selectedRecord && !readonly"
variant="outline"
size="icon"
@click="clearSelection"
class="shrink-0"
>
<X class="h-4 w-4" />
</Button>
</div>
<PopoverContent class="w-[400px] p-0">
<Command>
<CommandInput
v-model="searchQuery"
placeholder="Search..."
/>
<CommandEmpty>
{{ loading ? 'Loading...' : 'No results found.' }}
</CommandEmpty>
<CommandList>
<CommandGroup>
<CommandItem
v-for="record in filteredRecords"
:key="record.id"
:value="record.id"
@select="selectRecord(record)"
>
<Check
:class="cn(
'mr-2 h-4 w-4',
selectedRecord?.id === record.id ? 'opacity-100' : 'opacity-0'
)"
/>
{{ record[displayField] || record.id }}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<!-- Display readonly value -->
<div v-if="readonly && selectedRecord" class="text-sm text-muted-foreground">
{{ selectedRecord[displayField] || selectedRecord.id }}
</div>
</div>
</template>
<style scoped>
.lookup-field {
width: 100%;
}
</style>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { Check } from 'lucide-vue-next'
import { CheckboxIndicator, CheckboxRoot, type CheckboxRootEmits, type CheckboxRootProps, useForwardPropsEmits } from 'radix-vue'
import { computed, type HTMLAttributes } from 'vue'
import { cn } from '@/lib/utils'
const props = defineProps<CheckboxRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<CheckboxRootEmits>()
const delegatedProps = computed(() => {
const { class: _, ...delegated } = props
return delegated
})
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CheckboxRoot
v-bind="forwarded"
:class="
cn(
'peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',
props.class,
)
"
>
<CheckboxIndicator class="flex h-full w-full items-center justify-center text-current">
<Check class="h-4 w-4" />
</CheckboxIndicator>
</CheckboxRoot>
</template>

View File

@@ -4,7 +4,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
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 RelatedList from '@/components/RelatedList.vue'
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig, RelatedListConfig } from '@/types/field-types'
import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
import {
Collapsible,
@@ -13,7 +14,7 @@ import {
} from '@/components/ui/collapsible'
interface Props {
config: DetailViewConfig
config: DetailViewConfig & { relatedLists?: RelatedListConfig[] }
data: any
loading?: boolean
}
@@ -27,6 +28,8 @@ const emit = defineEmits<{
'delete': []
'back': []
'action': [actionId: string]
'navigate': [objectApiName: string, recordId: string]
'createRelated': [objectApiName: string, parentId: string]
}>()
// Organize fields into sections
@@ -47,7 +50,7 @@ const sections = computed<FieldSection[]>(() => {
const getFieldsBySection = (section: FieldSection) => {
return section.fields
.map(apiName => props.config.fields.find(f => f.apiName === apiName))
.filter(Boolean)
.filter((field): field is FieldConfig => field !== undefined)
}
</script>
@@ -121,6 +124,7 @@ const getFieldsBySection = (section: FieldSection) => {
:key="field.id"
:field="field"
:model-value="data[field.apiName]"
:record-data="data"
:mode="ViewMode.DETAIL"
/>
</div>
@@ -139,9 +143,10 @@ const getFieldsBySection = (section: FieldSection) => {
<div class="grid gap-6 md:grid-cols-2">
<FieldRenderer
v-for="field in getFieldsBySection(section)"
:key="field?.id"
:key="field.id"
:field="field"
:model-value="data[field.apiName]"
:record-data="data"
:mode="ViewMode.DETAIL"
/>
</div>
@@ -149,6 +154,19 @@ const getFieldsBySection = (section: FieldSection) => {
</template>
</Card>
</div>
<!-- Related Lists -->
<div v-if="config.relatedLists && config.relatedLists.length > 0" class="space-y-6">
<RelatedList
v-for="relatedList in config.relatedLists"
:key="relatedList.relationName"
:config="relatedList"
:parent-id="data.id"
:related-records="data[relatedList.relationName]"
@navigate="(objectApiName, recordId) => emit('navigate', objectApiName, recordId)"
@create="(objectApiName, parentId) => emit('createRelated', objectApiName, parentId)"
/>
</div>
</div>
</template>

View File

@@ -2,9 +2,12 @@
import { computed, ref, onMounted } from 'vue'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
import PageLayoutRenderer from '@/components/PageLayoutRenderer.vue'
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig } from '@/types/field-types'
import RelatedList from '@/components/RelatedList.vue'
import RecordSharing from '@/components/RecordSharing.vue'
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig, RelatedListConfig } from '@/types/field-types'
import { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
import {
Collapsible,
@@ -18,10 +21,14 @@ interface Props {
data: any
loading?: boolean
objectId?: string // For fetching page layout
baseUrl?: string
showSharing?: boolean
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
baseUrl: '/runtime/objects',
showSharing: true,
})
const emit = defineEmits<{
@@ -29,6 +36,8 @@ const emit = defineEmits<{
'delete': []
'back': []
'action': [actionId: string]
'navigate': [objectApiName: string, recordId: string]
'createRelated': [objectApiName: string, parentId: string]
}>()
const { getDefaultPageLayout } = usePageLayouts()
@@ -125,74 +134,123 @@ const usePageLayout = computed(() => {
<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>
<!-- Tabs for Details, Related, and Sharing -->
<Tabs v-else default-value="details" class="space-y-6">
<TabsList>
<TabsTrigger value="details">Details</TabsTrigger>
<TabsTrigger v-if="config.relatedLists && config.relatedLists.length > 0" value="related">
Related
</TabsTrigger>
<TabsTrigger v-if="showSharing && data.id" value="sharing">
Sharing
</TabsTrigger>
</TabsList>
<!-- 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"
>
<!-- Details Tab -->
<TabsContent value="details" class="space-y-6">
<!-- Content with Page Layout -->
<Card v-if="usePageLayout">
<CardHeader>
<CollapsibleTrigger class="flex items-center justify-between w-full hover:bg-muted/50 -m-2 p-2 rounded">
<div>
<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]"
:record-data="data"
:mode="ViewMode.DETAIL"
:base-url="baseUrl"
/>
</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>
</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>
</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]"
:record-data="data"
:mode="ViewMode.DETAIL"
:base-url="baseUrl"
/>
</div>
</CardContent>
</template>
</Card>
</div>
</TabsContent>
<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>
<!-- Related Lists Tab -->
<TabsContent value="related" class="space-y-6">
<div v-if="config.relatedLists && config.relatedLists.length > 0">
<RelatedList
v-for="relatedList in config.relatedLists"
:key="relatedList.relationName"
:config="relatedList"
:parent-id="data.id"
:related-records="data[relatedList.relationName]"
@navigate="(objectApiName, recordId) => emit('navigate', objectApiName, recordId)"
@create="(objectApiName, parentId) => emit('createRelated', objectApiName, parentId)"
/>
</div>
</TabsContent>
<!-- Sharing Tab -->
<TabsContent value="sharing">
<Card>
<CardContent class="pt-6">
<RecordSharing
v-if="data.id && config.objectApiName"
:object-api-name="config.objectApiName"
:record-id="data.id"
:owner-id="data.ownerId"
/>
</CardContent>
</template>
</Card>
</div>
</Card>
</TabsContent>
</Tabs>
</div>
</template>

View File

@@ -137,7 +137,12 @@ const validateForm = (): boolean => {
const handleSave = () => {
if (validateForm()) {
emit('save', { ...formData.value })
// Start with props.data to preserve system fields like id, then override with user edits
const dataToSave = {
...props.data,
...formData.value,
}
emit('save', dataToSave)
}
}

View File

@@ -19,12 +19,14 @@ interface Props {
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<{
@@ -158,7 +160,12 @@ const validateForm = (): boolean => {
const handleSave = () => {
if (validateForm()) {
emit('save', formData.value)
// Start with props.data to preserve system fields like id, then override with user edits
const saveData = {
...props.data,
...formData.value,
}
emit('save', saveData)
}
}
@@ -254,6 +261,7 @@ const handleFieldUpdate = (fieldName: string, value: any) => {
:model-value="formData[field.apiName]"
:mode="ViewMode.EDIT"
:error="errors[field.apiName]"
:base-url="baseUrl"
@update:model-value="handleFieldUpdate(field.apiName, $event)"
/>
</div>
@@ -277,6 +285,7 @@ const handleFieldUpdate = (fieldName: string, value: any) => {
:model-value="formData[field.apiName]"
:mode="ViewMode.EDIT"
:error="errors[field.apiName]"
:base-url="baseUrl"
@update:model-value="handleFieldUpdate(field.apiName, $event)"
/>
</div>

View File

@@ -21,12 +21,14 @@ interface Props {
data?: any[]
loading?: boolean
selectable?: boolean
baseUrl?: string
}
const props = withDefaults(defineProps<Props>(), {
data: () => [],
loading: false,
selectable: false,
baseUrl: '/runtime/objects',
})
const emit = defineEmits<{
@@ -205,7 +207,9 @@ const handleAction = (actionId: string) => {
<FieldRenderer
:field="field"
:model-value="row[field.apiName]"
:record-data="row"
:mode="ViewMode.LIST"
:base-url="baseUrl"
/>
</TableCell>
<TableCell @click.stop>