WIP - related lists and look up field
This commit is contained in:
219
RELATED_LISTS_IMPLEMENTATION.md
Normal file
219
RELATED_LISTS_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
# Related Lists and Lookup Fields Implementation
|
||||||
|
|
||||||
|
This document describes the implementation of related lists and improved relationship field handling in the application.
|
||||||
|
|
||||||
|
## Features Implemented
|
||||||
|
|
||||||
|
### 1. Related Lists Component (`/frontend/components/RelatedList.vue`)
|
||||||
|
|
||||||
|
A reusable component that displays related records for a parent entity in a table format.
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Displays related records in a formatted table
|
||||||
|
- Shows configurable fields for each related record
|
||||||
|
- Supports navigation to related record detail pages
|
||||||
|
- Allows creating new related records
|
||||||
|
- Handles loading and error states
|
||||||
|
- Empty state with call-to-action button
|
||||||
|
- Automatically fetches related records or uses provided data
|
||||||
|
|
||||||
|
**Usage Example:**
|
||||||
|
```vue
|
||||||
|
<RelatedList
|
||||||
|
:config="{
|
||||||
|
title: 'Domains',
|
||||||
|
relationName: 'domains',
|
||||||
|
objectApiName: 'domains',
|
||||||
|
fields: [...],
|
||||||
|
canCreate: true
|
||||||
|
}"
|
||||||
|
:parent-id="tenantId"
|
||||||
|
:related-records="tenant.domains"
|
||||||
|
@navigate="handleNavigate"
|
||||||
|
@create="handleCreate"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Lookup Field Component (`/frontend/components/fields/LookupField.vue`)
|
||||||
|
|
||||||
|
A searchable dropdown component for selecting related records (belongs-to relationships).
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- Searchable combobox for finding records
|
||||||
|
- Fetches available records from API
|
||||||
|
- Displays meaningful field names instead of UUIDs
|
||||||
|
- Clear button to remove selection
|
||||||
|
- Configurable relation object and display field
|
||||||
|
- Loading states
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```vue
|
||||||
|
<LookupField
|
||||||
|
:field="{
|
||||||
|
type: FieldType.BELONGS_TO,
|
||||||
|
relationObject: 'tenants',
|
||||||
|
relationDisplayField: 'name',
|
||||||
|
...
|
||||||
|
}"
|
||||||
|
v-model="domainData.tenantId"
|
||||||
|
base-url="/api/central"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Enhanced Field Renderer (`/frontend/components/fields/FieldRenderer.vue`)
|
||||||
|
|
||||||
|
Updated to handle relationship fields intelligently.
|
||||||
|
|
||||||
|
**New Features:**
|
||||||
|
- Detects BELONGS_TO field type
|
||||||
|
- Fetches related record for display in detail/list views
|
||||||
|
- Shows meaningful name instead of UUID
|
||||||
|
- Uses LookupField component for editing
|
||||||
|
- Automatic loading of related record data
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- **Detail/List View:** Fetches and displays related record name
|
||||||
|
- **Edit View:** Renders LookupField for selection
|
||||||
|
- Falls back to UUID if related record can't be fetched
|
||||||
|
|
||||||
|
### 4. Enhanced Detail View (`/frontend/components/views/DetailView.vue`)
|
||||||
|
|
||||||
|
Added support for displaying related lists below the main record details.
|
||||||
|
|
||||||
|
**New Features:**
|
||||||
|
- `relatedLists` configuration support
|
||||||
|
- Emits `navigate` and `createRelated` events
|
||||||
|
- Passes related records data to RelatedList components
|
||||||
|
- Automatically displays all configured related lists
|
||||||
|
|
||||||
|
### 5. Type Definitions (`/frontend/types/field-types.ts`)
|
||||||
|
|
||||||
|
Added new types for related list configuration:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface RelatedListConfig {
|
||||||
|
title: string;
|
||||||
|
relationName: string; // Property name on parent object
|
||||||
|
objectApiName: string; // API endpoint name
|
||||||
|
fields: FieldConfig[]; // Fields to display in list
|
||||||
|
canCreate?: boolean;
|
||||||
|
createRoute?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DetailViewConfig extends ViewConfig {
|
||||||
|
mode: ViewMode.DETAIL;
|
||||||
|
sections?: FieldSection[];
|
||||||
|
actions?: ViewAction[];
|
||||||
|
relatedLists?: RelatedListConfig[]; // NEW
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Backend Support (`/backend/src/tenant/central-admin.controller.ts`)
|
||||||
|
|
||||||
|
Added filtering support for fetching related records.
|
||||||
|
|
||||||
|
**Enhancement:**
|
||||||
|
```typescript
|
||||||
|
@Get('domains')
|
||||||
|
async getDomains(
|
||||||
|
@Req() req: any,
|
||||||
|
@Query('parentId') parentId?: string,
|
||||||
|
@Query('tenantId') tenantId?: string,
|
||||||
|
) {
|
||||||
|
// ...
|
||||||
|
if (parentId || tenantId) {
|
||||||
|
query = query.where('tenantId', parentId || tenantId);
|
||||||
|
}
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Central Entities Configuration (`/frontend/composables/useCentralEntities.ts`)
|
||||||
|
|
||||||
|
Added related list configurations to tenant detail view:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const tenantDetailConfig: DetailViewConfig = {
|
||||||
|
// ... existing config
|
||||||
|
relatedLists: [
|
||||||
|
{
|
||||||
|
title: 'Domains',
|
||||||
|
relationName: 'domains',
|
||||||
|
objectApiName: 'domains',
|
||||||
|
fields: [
|
||||||
|
{ id: 'domain', apiName: 'domain', label: 'Domain', type: FieldType.TEXT },
|
||||||
|
{ id: 'isPrimary', apiName: 'isPrimary', label: 'Primary', type: FieldType.BOOLEAN },
|
||||||
|
{ id: 'createdAt', apiName: 'createdAt', label: 'Created', type: FieldType.DATETIME },
|
||||||
|
],
|
||||||
|
canCreate: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Updated domain field configuration to use lookup:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
id: 'tenantId',
|
||||||
|
apiName: 'tenantId',
|
||||||
|
label: 'Tenant',
|
||||||
|
type: FieldType.BELONGS_TO, // Changed from TEXT
|
||||||
|
relationObject: 'tenants',
|
||||||
|
relationDisplayField: 'name',
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## User Experience Improvements
|
||||||
|
|
||||||
|
### Before:
|
||||||
|
- **Relationship Fields:** Displayed raw UUIDs everywhere
|
||||||
|
- **Editing Relationships:** Had to manually enter or paste UUIDs
|
||||||
|
- **Related Records:** No way to see child records from parent detail page
|
||||||
|
- **Navigation:** Had to manually navigate to related record lists
|
||||||
|
|
||||||
|
### After:
|
||||||
|
- **Relationship Fields:** Show meaningful names (e.g., "Acme Corp" instead of "abc-123-def")
|
||||||
|
- **Editing Relationships:** Searchable dropdown with all available options
|
||||||
|
- **Related Records:** Automatically displayed in related lists on detail pages
|
||||||
|
- **Navigation:** One-click navigation to related records; create button with parent context pre-filled
|
||||||
|
|
||||||
|
## Example: Tenant Detail View
|
||||||
|
|
||||||
|
When viewing a tenant, users now see:
|
||||||
|
|
||||||
|
1. **Main tenant information** (name, slug, status, database config)
|
||||||
|
2. **Related Lists section** below main details:
|
||||||
|
- **Domains list** showing all domains for this tenant
|
||||||
|
- Each domain row displays: domain name, isPrimary flag, created date
|
||||||
|
- "New" button to create domain with tenantId pre-filled
|
||||||
|
- Click any domain to navigate to its detail page
|
||||||
|
|
||||||
|
## Example: Creating a Domain
|
||||||
|
|
||||||
|
When creating/editing a domain:
|
||||||
|
|
||||||
|
1. **Tenant field** shows a searchable dropdown instead of text input
|
||||||
|
2. Type to search available tenants by name
|
||||||
|
3. Select from list - shows "Acme Corp" not "uuid-123"
|
||||||
|
4. Selected tenant's name is displayed
|
||||||
|
5. Can clear selection with X button
|
||||||
|
|
||||||
|
## Technical Notes
|
||||||
|
|
||||||
|
- All API calls use the centralized `$api` helper from `useNuxtApp()`
|
||||||
|
- Type casting via `unknown` to handle NuxtApp type issues
|
||||||
|
- Filter functions use TypeScript type predicates for proper type narrowing
|
||||||
|
- Related records can be passed in (if already fetched with parent) or fetched separately
|
||||||
|
- Backend supports both `parentId` and specific relationship field names (e.g., `tenantId`)
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
Potential additions:
|
||||||
|
- Inline editing within related lists
|
||||||
|
- Pagination for large related lists
|
||||||
|
- Sorting and filtering within related lists
|
||||||
|
- Bulk operations on related records
|
||||||
|
- Many-to-many relationship support
|
||||||
|
- Has-many relationship support with junction tables
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Body,
|
Body,
|
||||||
Param,
|
Param,
|
||||||
|
Query,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
UnauthorizedException,
|
UnauthorizedException,
|
||||||
Req,
|
Req,
|
||||||
@@ -112,9 +113,21 @@ export class CentralAdminController {
|
|||||||
// ==================== DOMAINS ====================
|
// ==================== DOMAINS ====================
|
||||||
|
|
||||||
@Get('domains')
|
@Get('domains')
|
||||||
async getDomains(@Req() req: any) {
|
async getDomains(
|
||||||
|
@Req() req: any,
|
||||||
|
@Query('parentId') parentId?: string,
|
||||||
|
@Query('tenantId') tenantId?: string,
|
||||||
|
) {
|
||||||
this.checkCentralAdmin(req);
|
this.checkCentralAdmin(req);
|
||||||
return CentralDomain.query().withGraphFetched('tenant');
|
|
||||||
|
let query = CentralDomain.query().withGraphFetched('tenant');
|
||||||
|
|
||||||
|
// Filter by parent/tenant ID if provided (for related lists)
|
||||||
|
if (parentId || tenantId) {
|
||||||
|
query = query.where('tenantId', parentId || tenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('domains/:id')
|
@Get('domains/:id')
|
||||||
|
|||||||
186
frontend/components/RelatedList.vue
Normal file
186
frontend/components/RelatedList.vue
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
<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 '/api/central'
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
baseUrl: '/api/central',
|
||||||
|
relatedRecords: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'navigate': [objectApiName: string, recordId: string]
|
||||||
|
'create': [objectApiName: string, parentId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { $api } = useNuxtApp() as unknown as { $api: Function }
|
||||||
|
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 {
|
||||||
|
const response = await $api(`${props.baseUrl}/${props.config.objectApiName}`, {
|
||||||
|
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>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, ref, watch, onMounted } from 'vue'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
@@ -9,19 +9,31 @@ import { DatePicker } from '@/components/ui/date-picker'
|
|||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { FieldConfig, FieldType, ViewMode } from '@/types/field-types'
|
import { FieldConfig, FieldType, ViewMode } from '@/types/field-types'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
import LookupField from '@/components/fields/LookupField.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
field: FieldConfig
|
field: FieldConfig
|
||||||
modelValue: any
|
modelValue: any
|
||||||
mode: ViewMode
|
mode: ViewMode
|
||||||
readonly?: boolean
|
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: '/api/central',
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: any]
|
'update:modelValue': [value: any]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { $api } = useNuxtApp() as unknown as { $api: Function }
|
||||||
|
|
||||||
|
// For relationship fields, store the related record for display
|
||||||
|
const relatedRecord = ref<any | null>(null)
|
||||||
|
const loadingRelated = ref(false)
|
||||||
|
|
||||||
const value = computed({
|
const value = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: (val) => emit('update:modelValue', val),
|
set: (val) => emit('update:modelValue', val),
|
||||||
@@ -32,10 +44,88 @@ const isEditMode = computed(() => props.mode === ViewMode.EDIT)
|
|||||||
const isListMode = computed(() => props.mode === ViewMode.LIST)
|
const isListMode = computed(() => props.mode === ViewMode.LIST)
|
||||||
const isDetailMode = computed(() => props.mode === ViewMode.DETAIL)
|
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 (e.g., 'tenants' -> singular 'tenant')
|
||||||
|
const getRelationPropertyName = () => {
|
||||||
|
const relationObject = props.field.relationObject || props.field.apiName.replace('Id', '')
|
||||||
|
// Convert plural to singular for property name (e.g., 'tenants' -> 'tenant')
|
||||||
|
return relationObject.endsWith('s') ? relationObject.slice(0, -1) : relationObject
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch related record for display
|
||||||
|
const fetchRelatedRecord = async () => {
|
||||||
|
if (!isRelationshipField.value || !props.modelValue) return
|
||||||
|
|
||||||
|
const relationObject = props.field.relationObject || props.field.apiName.replace('Id', '')
|
||||||
|
const displayField = props.field.relationDisplayField || 'name'
|
||||||
|
|
||||||
|
loadingRelated.value = true
|
||||||
|
try {
|
||||||
|
const record = await $api(`${props.baseUrl}/${relationObject}/${props.modelValue}`)
|
||||||
|
relatedRecord.value = record
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching related record:', err)
|
||||||
|
relatedRecord.value = null
|
||||||
|
} finally {
|
||||||
|
loadingRelated.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise use the fetched related record
|
||||||
|
if (relatedRecord.value) {
|
||||||
|
const displayField = props.field.relationDisplayField || 'name'
|
||||||
|
return relatedRecord.value[displayField] || relatedRecord.value.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
if (loadingRelated.value) {
|
||||||
|
return 'Loading...'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to ID
|
||||||
|
return props.modelValue || '-'
|
||||||
|
})
|
||||||
|
|
||||||
|
// Watch for changes in modelValue for relationship fields
|
||||||
|
watch(() => props.modelValue, () => {
|
||||||
|
if (isRelationshipField.value && (isDetailMode.value || isListMode.value)) {
|
||||||
|
fetchRelatedRecord()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load related record on mount if needed
|
||||||
|
onMounted(() => {
|
||||||
|
if (isRelationshipField.value && props.modelValue && (isDetailMode.value || isListMode.value)) {
|
||||||
|
fetchRelatedRecord()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const formatValue = (val: any): string => {
|
const formatValue = (val: any): string => {
|
||||||
if (val === null || val === undefined) return '-'
|
if (val === null || val === undefined) return '-'
|
||||||
|
|
||||||
switch (props.field.type) {
|
switch (props.field.type) {
|
||||||
|
case FieldType.BELONGS_TO:
|
||||||
|
return relationshipDisplayValue.value
|
||||||
case FieldType.DATE:
|
case FieldType.DATE:
|
||||||
return val instanceof Date ? val.toLocaleDateString() : new Date(val).toLocaleDateString()
|
return val instanceof Date ? val.toLocaleDateString() : new Date(val).toLocaleDateString()
|
||||||
case FieldType.DATETIME:
|
case FieldType.DATETIME:
|
||||||
@@ -113,9 +203,17 @@ const formatValue = (val: any): string => {
|
|||||||
|
|
||||||
<!-- Edit View - Input components -->
|
<!-- Edit View - Input components -->
|
||||||
<div v-else-if="isEditMode && !isReadOnly">
|
<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 -->
|
<!-- Text Input -->
|
||||||
<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"
|
:id="field.id"
|
||||||
v-model="value"
|
v-model="value"
|
||||||
:type="field.type === FieldType.PASSWORD ? 'password' : field.type === FieldType.EMAIL ? 'email' : field.type === FieldType.URL ? 'url' : 'text'"
|
:type="field.type === FieldType.PASSWORD ? 'password' : field.type === FieldType.EMAIL ? 'email' : field.type === FieldType.URL ? 'url' : 'text'"
|
||||||
|
|||||||
170
frontend/components/fields/LookupField.vue
Normal file
170
frontend/components/fields/LookupField.vue
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<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 '/api/central'
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
baseUrl: '/api/central',
|
||||||
|
modelValue: null,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string | null]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { $api } = useNuxtApp() as unknown as { $api: Function }
|
||||||
|
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 response = await $api(`${props.baseUrl}/${relationObject.value}`)
|
||||||
|
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>
|
||||||
@@ -4,7 +4,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
|
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 { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
@@ -13,7 +14,7 @@ import {
|
|||||||
} from '@/components/ui/collapsible'
|
} from '@/components/ui/collapsible'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
config: DetailViewConfig
|
config: DetailViewConfig & { relatedLists?: RelatedListConfig[] }
|
||||||
data: any
|
data: any
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
}
|
}
|
||||||
@@ -27,6 +28,8 @@ const emit = defineEmits<{
|
|||||||
'delete': []
|
'delete': []
|
||||||
'back': []
|
'back': []
|
||||||
'action': [actionId: string]
|
'action': [actionId: string]
|
||||||
|
'navigate': [objectApiName: string, recordId: string]
|
||||||
|
'createRelated': [objectApiName: string, parentId: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Organize fields into sections
|
// Organize fields into sections
|
||||||
@@ -47,7 +50,7 @@ const sections = computed<FieldSection[]>(() => {
|
|||||||
const getFieldsBySection = (section: FieldSection) => {
|
const getFieldsBySection = (section: FieldSection) => {
|
||||||
return section.fields
|
return section.fields
|
||||||
.map(apiName => props.config.fields.find(f => f.apiName === apiName))
|
.map(apiName => props.config.fields.find(f => f.apiName === apiName))
|
||||||
.filter(Boolean)
|
.filter((field): field is FieldConfig => field !== undefined)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -121,6 +124,7 @@ const getFieldsBySection = (section: FieldSection) => {
|
|||||||
:key="field.id"
|
:key="field.id"
|
||||||
:field="field"
|
:field="field"
|
||||||
:model-value="data[field.apiName]"
|
:model-value="data[field.apiName]"
|
||||||
|
:record-data="data"
|
||||||
:mode="ViewMode.DETAIL"
|
:mode="ViewMode.DETAIL"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,9 +143,10 @@ const getFieldsBySection = (section: FieldSection) => {
|
|||||||
<div class="grid gap-6 md:grid-cols-2">
|
<div class="grid gap-6 md:grid-cols-2">
|
||||||
<FieldRenderer
|
<FieldRenderer
|
||||||
v-for="field in getFieldsBySection(section)"
|
v-for="field in getFieldsBySection(section)"
|
||||||
:key="field?.id"
|
:key="field.id"
|
||||||
:field="field"
|
:field="field"
|
||||||
:model-value="data[field.apiName]"
|
:model-value="data[field.apiName]"
|
||||||
|
:record-data="data"
|
||||||
:mode="ViewMode.DETAIL"
|
:mode="ViewMode.DETAIL"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,6 +154,19 @@ const getFieldsBySection = (section: FieldSection) => {
|
|||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
|
import FieldRenderer from '@/components/fields/FieldRenderer.vue'
|
||||||
import PageLayoutRenderer from '@/components/PageLayoutRenderer.vue'
|
import PageLayoutRenderer from '@/components/PageLayoutRenderer.vue'
|
||||||
import { DetailViewConfig, ViewMode, FieldSection, FieldConfig } 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 { Edit, Trash2, ArrowLeft } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
Collapsible,
|
Collapsible,
|
||||||
@@ -29,6 +30,8 @@ const emit = defineEmits<{
|
|||||||
'delete': []
|
'delete': []
|
||||||
'back': []
|
'back': []
|
||||||
'action': [actionId: string]
|
'action': [actionId: string]
|
||||||
|
'navigate': [objectApiName: string, recordId: string]
|
||||||
|
'createRelated': [objectApiName: string, parentId: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { getDefaultPageLayout } = usePageLayouts()
|
const { getDefaultPageLayout } = usePageLayouts()
|
||||||
@@ -165,6 +168,7 @@ const usePageLayout = computed(() => {
|
|||||||
:key="field.id"
|
:key="field.id"
|
||||||
:field="field"
|
:field="field"
|
||||||
:model-value="data[field.apiName]"
|
:model-value="data[field.apiName]"
|
||||||
|
:record-data="data"
|
||||||
:mode="ViewMode.DETAIL"
|
:mode="ViewMode.DETAIL"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,6 +190,7 @@ const usePageLayout = computed(() => {
|
|||||||
:key="field?.id"
|
:key="field?.id"
|
||||||
:field="field"
|
:field="field"
|
||||||
:model-value="data[field.apiName]"
|
:model-value="data[field.apiName]"
|
||||||
|
:record-data="data"
|
||||||
:mode="ViewMode.DETAIL"
|
:mode="ViewMode.DETAIL"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,6 +198,19 @@ const usePageLayout = computed(() => {
|
|||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ const handleAction = (actionId: string) => {
|
|||||||
<FieldRenderer
|
<FieldRenderer
|
||||||
:field="field"
|
:field="field"
|
||||||
:model-value="row[field.apiName]"
|
:model-value="row[field.apiName]"
|
||||||
|
:record-data="row"
|
||||||
:mode="ViewMode.LIST"
|
:mode="ViewMode.LIST"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { FieldType, ViewMode } from '@/types/field-types'
|
import { FieldType, ViewMode } from '@/types/field-types'
|
||||||
import type { FieldConfig, ListViewConfig, DetailViewConfig, EditViewConfig } from '@/types/field-types'
|
import type { FieldConfig, ListViewConfig, DetailViewConfig, EditViewConfig, RelatedListConfig } from '@/types/field-types'
|
||||||
|
|
||||||
// ==================== TENANTS ====================
|
// ==================== TENANTS ====================
|
||||||
|
|
||||||
@@ -155,6 +155,19 @@ export const tenantDetailConfig: DetailViewConfig = {
|
|||||||
collapsible: true,
|
collapsible: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
relatedLists: [
|
||||||
|
{
|
||||||
|
title: 'Domains',
|
||||||
|
relationName: 'domains',
|
||||||
|
objectApiName: 'domains',
|
||||||
|
fields: [
|
||||||
|
{ id: 'domain', apiName: 'domain', label: 'Domain', type: FieldType.TEXT },
|
||||||
|
{ id: 'isPrimary', apiName: 'isPrimary', label: 'Primary', type: FieldType.BOOLEAN },
|
||||||
|
{ id: 'createdAt', apiName: 'createdAt', label: 'Created', type: FieldType.DATETIME },
|
||||||
|
],
|
||||||
|
canCreate: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tenantEditConfig: EditViewConfig = {
|
export const tenantEditConfig: EditViewConfig = {
|
||||||
@@ -200,7 +213,7 @@ export const domainFields: FieldConfig[] = [
|
|||||||
showOnList: true,
|
showOnList: true,
|
||||||
showOnDetail: true,
|
showOnDetail: true,
|
||||||
showOnEdit: true,
|
showOnEdit: true,
|
||||||
relationObject: 'Tenant',
|
relationObject: 'tenants',
|
||||||
relationDisplayField: 'name',
|
relationDisplayField: 'name',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -81,6 +81,20 @@ const handleDelete = async (rows: any[]) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle navigation to related records
|
||||||
|
const handleNavigate = (objectApiName: string, recordId: string) => {
|
||||||
|
router.push(`/central/${objectApiName}/${recordId}/detail`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle creating related records
|
||||||
|
const handleCreateRelated = (objectApiName: string, parentId: string) => {
|
||||||
|
// Navigate to create page with parent context
|
||||||
|
router.push({
|
||||||
|
path: `/central/${objectApiName}/new`,
|
||||||
|
query: { tenantId: parentId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleSaveRecord = async (data: any) => {
|
const handleSaveRecord = async (data: any) => {
|
||||||
try {
|
try {
|
||||||
const savedRecord = await handleSave(data)
|
const savedRecord = await handleSave(data)
|
||||||
@@ -137,6 +151,8 @@ onMounted(async () => {
|
|||||||
@edit="handleEdit"
|
@edit="handleEdit"
|
||||||
@delete="() => handleDelete([currentRecord])"
|
@delete="() => handleDelete([currentRecord])"
|
||||||
@back="handleBack"
|
@back="handleBack"
|
||||||
|
@navigate="handleNavigate"
|
||||||
|
@create-related="handleCreateRelated"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Edit View -->
|
<!-- Edit View -->
|
||||||
|
|||||||
@@ -118,10 +118,20 @@ export interface ListViewConfig extends ViewConfig {
|
|||||||
actions?: ViewAction[];
|
actions?: ViewAction[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RelatedListConfig {
|
||||||
|
title: string;
|
||||||
|
relationName: string;
|
||||||
|
objectApiName: string;
|
||||||
|
fields: FieldConfig[];
|
||||||
|
canCreate?: boolean;
|
||||||
|
createRoute?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DetailViewConfig extends ViewConfig {
|
export interface DetailViewConfig extends ViewConfig {
|
||||||
mode: ViewMode.DETAIL;
|
mode: ViewMode.DETAIL;
|
||||||
sections?: FieldSection[];
|
sections?: FieldSection[];
|
||||||
actions?: ViewAction[];
|
actions?: ViewAction[];
|
||||||
|
relatedLists?: RelatedListConfig[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EditViewConfig extends ViewConfig {
|
export interface EditViewConfig extends ViewConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user