Add dynamic related lists with page layout support
This commit is contained in:
@@ -43,6 +43,14 @@ export interface ObjectDefinitionDTO {
|
||||
description?: string;
|
||||
isSystem: boolean;
|
||||
fields: FieldConfigDTO[];
|
||||
relatedLists?: Array<{
|
||||
title: string;
|
||||
relationName: string;
|
||||
objectApiName: string;
|
||||
fields: FieldConfigDTO[];
|
||||
canCreate?: boolean;
|
||||
createRoute?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -206,6 +214,17 @@ export class FieldMapperService {
|
||||
.filter((f: any) => f.isActive !== false)
|
||||
.sort((a: any, b: any) => (a.displayOrder || 0) - (b.displayOrder || 0))
|
||||
.map((f: any) => this.mapFieldToDTO(f)),
|
||||
relatedLists: (objectDef.relatedLists || []).map((list: any) => ({
|
||||
title: list.title,
|
||||
relationName: list.relationName,
|
||||
objectApiName: list.objectApiName,
|
||||
fields: (list.fields || [])
|
||||
.filter((f: any) => f.isActive !== false)
|
||||
.map((f: any) => this.mapFieldToDTO(f))
|
||||
.filter((f: any) => f.showOnList !== false),
|
||||
canCreate: list.canCreate,
|
||||
createRoute: list.createRoute,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -119,6 +119,47 @@ export class DynamicModelFactory {
|
||||
};
|
||||
}
|
||||
|
||||
// Add additional relation mappings (e.g., hasMany)
|
||||
for (const relation of relations) {
|
||||
if (mappings[relation.name]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let modelClass: any = relation.targetObjectApiName;
|
||||
if (getModel) {
|
||||
const resolvedModel = getModel(relation.targetObjectApiName);
|
||||
if (resolvedModel) {
|
||||
modelClass = resolvedModel;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const targetTable = this.getTableName(relation.targetObjectApiName);
|
||||
|
||||
if (relation.type === 'belongsTo') {
|
||||
mappings[relation.name] = {
|
||||
relation: Model.BelongsToOneRelation,
|
||||
modelClass,
|
||||
join: {
|
||||
from: `${tableName}.${relation.fromColumn}`,
|
||||
to: `${targetTable}.${relation.toColumn}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (relation.type === 'hasMany') {
|
||||
mappings[relation.name] = {
|
||||
relation: Model.HasManyRelation,
|
||||
modelClass,
|
||||
join: {
|
||||
from: `${tableName}.${relation.fromColumn}`,
|
||||
to: `${targetTable}.${relation.toColumn}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return mappings;
|
||||
}
|
||||
|
||||
@@ -196,6 +237,9 @@ export class DynamicModelFactory {
|
||||
.replace(/([A-Z])/g, '_$1')
|
||||
.toLowerCase()
|
||||
.replace(/^_/, '');
|
||||
if (snakeCase.endsWith('y')) {
|
||||
return `${snakeCase.slice(0, -1)}ies`;
|
||||
}
|
||||
return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,6 +171,25 @@ export class ModelService {
|
||||
}
|
||||
}
|
||||
|
||||
if (objectMetadata.relations) {
|
||||
for (const relation of objectMetadata.relations) {
|
||||
if (relation.targetObjectApiName) {
|
||||
try {
|
||||
await this.ensureModelWithDependencies(
|
||||
tenantId,
|
||||
relation.targetObjectApiName,
|
||||
fetchMetadata,
|
||||
visited,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.debug(
|
||||
`Skipping registration of related model ${relation.targetObjectApiName}: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Now create and register this model (all dependencies are ready)
|
||||
await this.createModelForObject(tenantId, objectMetadata);
|
||||
this.logger.log(`Registered model for ${objectApiName} in tenant ${tenantId}`);
|
||||
|
||||
@@ -71,10 +71,13 @@ export class ObjectService {
|
||||
.first();
|
||||
}
|
||||
|
||||
const relatedLists = await this.getRelatedListDefinitions(resolvedTenantId, apiName);
|
||||
|
||||
return {
|
||||
...obj,
|
||||
fields: normalizedFields,
|
||||
app,
|
||||
relatedLists,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -409,6 +412,17 @@ export class ObjectService {
|
||||
});
|
||||
}
|
||||
|
||||
const relatedLists = await this.getRelatedListDefinitions(tenantId, apiName);
|
||||
for (const relatedList of relatedLists) {
|
||||
validRelations.push({
|
||||
name: relatedList.relationName,
|
||||
type: 'hasMany' as const,
|
||||
targetObjectApiName: relatedList.objectApiName,
|
||||
fromColumn: 'id',
|
||||
toColumn: relatedList.lookupFieldApiName,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
apiName,
|
||||
tableName,
|
||||
@@ -566,17 +580,19 @@ export class ObjectService {
|
||||
f.type === 'LOOKUP' && f.referenceObject
|
||||
) || [];
|
||||
|
||||
if (lookupFields.length > 0) {
|
||||
// Build relation expression - use singular lowercase for relation name
|
||||
const relationExpression = lookupFields
|
||||
.map(f => f.apiName.replace(/Id$/, '').toLowerCase())
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
const relatedLists = await this.getRelatedListDefinitions(resolvedTenantId, objectApiName);
|
||||
|
||||
if (relationExpression) {
|
||||
const relationNames = [
|
||||
...lookupFields
|
||||
.map(f => f.apiName.replace(/Id$/, '').toLowerCase())
|
||||
.filter(Boolean),
|
||||
...relatedLists.map(list => list.relationName),
|
||||
];
|
||||
|
||||
if (relationNames.length > 0) {
|
||||
const relationExpression = relationNames.join(', ');
|
||||
query = query.withGraphFetched(`[${relationExpression}]`);
|
||||
}
|
||||
}
|
||||
|
||||
const record = await query.first();
|
||||
if (!record) {
|
||||
@@ -589,6 +605,79 @@ export class ObjectService {
|
||||
return filteredRecord;
|
||||
}
|
||||
|
||||
private async getRelatedListDefinitions(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
): Promise<Array<{
|
||||
title: string;
|
||||
relationName: string;
|
||||
objectApiName: string;
|
||||
lookupFieldApiName: string;
|
||||
fields: any[];
|
||||
}>> {
|
||||
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
|
||||
|
||||
const relatedLookups = await knex('field_definitions as fd')
|
||||
.join('object_definitions as od', 'fd.objectDefinitionId', 'od.id')
|
||||
.where('fd.type', 'LOOKUP')
|
||||
.andWhere('fd.referenceObject', objectApiName)
|
||||
.select(
|
||||
'fd.apiName as fieldApiName',
|
||||
'fd.label as fieldLabel',
|
||||
'fd.objectDefinitionId as objectDefinitionId',
|
||||
'od.apiName as childApiName',
|
||||
'od.label as childLabel',
|
||||
'od.pluralLabel as childPluralLabel',
|
||||
);
|
||||
|
||||
if (relatedLookups.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const objectIds = Array.from(
|
||||
new Set(relatedLookups.map((lookup: any) => lookup.objectDefinitionId)),
|
||||
);
|
||||
|
||||
const relatedFields = await knex('field_definitions')
|
||||
.whereIn('objectDefinitionId', objectIds)
|
||||
.orderBy('label', 'asc');
|
||||
|
||||
const fieldsByObject = new Map<string, any[]>();
|
||||
for (const field of relatedFields) {
|
||||
const existing = fieldsByObject.get(field.objectDefinitionId) || [];
|
||||
existing.push(this.normalizeField(field));
|
||||
fieldsByObject.set(field.objectDefinitionId, existing);
|
||||
}
|
||||
|
||||
const lookupCounts = relatedLookups.reduce<Record<string, number>>(
|
||||
(acc, lookup: any) => {
|
||||
acc[lookup.childApiName] = (acc[lookup.childApiName] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return relatedLookups.map((lookup: any) => {
|
||||
const baseRelationName = this.getTableName(lookup.childApiName);
|
||||
const hasMultiple = lookupCounts[lookup.childApiName] > 1;
|
||||
const relationName = hasMultiple
|
||||
? `${baseRelationName}_${lookup.fieldApiName.replace(/Id$/, '').toLowerCase()}`
|
||||
: baseRelationName;
|
||||
const baseTitle =
|
||||
lookup.childPluralLabel ||
|
||||
(lookup.childLabel ? `${lookup.childLabel}s` : lookup.childApiName);
|
||||
const title = hasMultiple ? `${baseTitle} (${lookup.fieldLabel})` : baseTitle;
|
||||
|
||||
return {
|
||||
title,
|
||||
relationName,
|
||||
objectApiName: lookup.childApiName,
|
||||
lookupFieldApiName: lookup.fieldApiName,
|
||||
fields: fieldsByObject.get(lookup.objectDefinitionId) || [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async createRecord(
|
||||
tenantId: string,
|
||||
objectApiName: string,
|
||||
|
||||
@@ -20,6 +20,7 @@ export class CreatePageLayoutDto {
|
||||
w: number;
|
||||
h: number;
|
||||
}>;
|
||||
relatedLists?: string[];
|
||||
};
|
||||
|
||||
@IsString()
|
||||
@@ -46,6 +47,7 @@ export class UpdatePageLayoutDto {
|
||||
w: number;
|
||||
h: number;
|
||||
}>;
|
||||
relatedLists?: string[];
|
||||
};
|
||||
|
||||
@IsString()
|
||||
|
||||
@@ -28,7 +28,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Available Fields Sidebar -->
|
||||
<div class="w-80 border-l bg-white dark:bg-slate-950 p-4 overflow-auto">
|
||||
<div class="w-80 border-l bg-white dark:bg-slate-950 p-4 overflow-auto space-y-6">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4">Available Fields</h3>
|
||||
<p class="text-xs text-muted-foreground mb-4">Click or drag to add field to grid</p>
|
||||
<div class="space-y-2" id="sidebar-fields">
|
||||
@@ -47,31 +48,55 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="relatedLists.length > 0">
|
||||
<h3 class="text-lg font-semibold mb-2">Related Lists</h3>
|
||||
<p class="text-xs text-muted-foreground mb-4">Select related lists to show on detail views</p>
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
v-for="list in relatedLists"
|
||||
:key="list.relationName"
|
||||
class="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:value="list.relationName"
|
||||
v-model="selectedRelatedLists"
|
||||
/>
|
||||
<span>{{ list.title }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
||||
import { GridStack } from 'gridstack'
|
||||
import 'gridstack/dist/gridstack.min.css'
|
||||
import type { FieldLayoutItem } from '~/types/page-layout'
|
||||
import type { FieldConfig } from '~/types/field-types'
|
||||
import type { FieldConfig, RelatedListConfig } from '~/types/field-types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
const props = defineProps<{
|
||||
fields: FieldConfig[]
|
||||
relatedLists?: RelatedListConfig[]
|
||||
initialLayout?: FieldLayoutItem[]
|
||||
initialRelatedLists?: string[]
|
||||
layoutName?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [layout: FieldLayoutItem[]]
|
||||
save: [layout: { fields: FieldLayoutItem[]; relatedLists: string[] }]
|
||||
}>()
|
||||
|
||||
const gridContainer = ref<HTMLElement | null>(null)
|
||||
let grid: GridStack | null = null
|
||||
const gridItems = ref<Map<string, any>>(new Map())
|
||||
const selectedRelatedLists = ref<string[]>(props.initialRelatedLists || [])
|
||||
|
||||
// Fields that are already on the grid
|
||||
const placedFieldIds = ref<Set<string>>(new Set())
|
||||
@@ -81,6 +106,10 @@ const availableFields = computed(() => {
|
||||
return props.fields.filter(field => !placedFieldIds.value.has(field.id))
|
||||
})
|
||||
|
||||
const relatedLists = computed(() => {
|
||||
return props.relatedLists || []
|
||||
})
|
||||
|
||||
const initGrid = () => {
|
||||
if (!gridContainer.value) return
|
||||
|
||||
@@ -278,7 +307,10 @@ const handleSave = () => {
|
||||
}
|
||||
})
|
||||
|
||||
emit('save', layout)
|
||||
emit('save', {
|
||||
fields: layout,
|
||||
relatedLists: selectedRelatedLists.value,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -295,6 +327,13 @@ onBeforeUnmount(() => {
|
||||
watch(() => props.fields, () => {
|
||||
updatePlacedFields()
|
||||
}, { deep: true })
|
||||
|
||||
watch(
|
||||
() => props.initialRelatedLists,
|
||||
(value) => {
|
||||
selectedRelatedLists.value = value ? [...value] : []
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -87,6 +87,22 @@ const getFieldsBySection = (section: FieldSection) => {
|
||||
const usePageLayout = computed(() => {
|
||||
return pageLayout.value && pageLayout.value.fields && pageLayout.value.fields.length > 0
|
||||
})
|
||||
|
||||
const visibleRelatedLists = computed<RelatedListConfig[]>(() => {
|
||||
const relatedLists = props.config.relatedLists || []
|
||||
if (!relatedLists.length) return []
|
||||
|
||||
if (!usePageLayout.value) {
|
||||
return relatedLists
|
||||
}
|
||||
|
||||
const layoutRelatedLists = pageLayout.value?.relatedLists
|
||||
if (!layoutRelatedLists || layoutRelatedLists.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return relatedLists.filter(list => layoutRelatedLists.includes(list.relationName))
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -138,7 +154,7 @@ const usePageLayout = computed(() => {
|
||||
<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">
|
||||
<TabsTrigger v-if="visibleRelatedLists.length > 0" value="related">
|
||||
Related
|
||||
</TabsTrigger>
|
||||
<TabsTrigger v-if="showSharing && data.id" value="sharing">
|
||||
@@ -224,9 +240,9 @@ const usePageLayout = computed(() => {
|
||||
|
||||
<!-- Related Lists Tab -->
|
||||
<TabsContent value="related" class="space-y-6">
|
||||
<div v-if="config.relatedLists && config.relatedLists.length > 0">
|
||||
<div v-if="visibleRelatedLists.length > 0">
|
||||
<RelatedList
|
||||
v-for="relatedList in config.relatedLists"
|
||||
v-for="relatedList in visibleRelatedLists"
|
||||
:key="relatedList.relationName"
|
||||
:config="relatedList"
|
||||
:parent-id="data.id"
|
||||
|
||||
@@ -97,6 +97,7 @@ export const useFields = () => {
|
||||
objectApiName: objectDef.apiName,
|
||||
mode: 'detail' as ViewMode,
|
||||
fields,
|
||||
relatedLists: objectDef.relatedLists || [],
|
||||
...customConfig,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +155,14 @@ const handleBack = () => {
|
||||
router.push(`/${objectApiName.value.toLowerCase()}/`)
|
||||
}
|
||||
|
||||
const handleNavigate = (relatedObjectApiName: string, relatedRecordId: string) => {
|
||||
router.push(`/${relatedObjectApiName.toLowerCase()}/${relatedRecordId}/detail`)
|
||||
}
|
||||
|
||||
const handleCreateRelated = (relatedObjectApiName: string, _parentId: string) => {
|
||||
router.push(`/${relatedObjectApiName.toLowerCase()}/new`)
|
||||
}
|
||||
|
||||
const handleDelete = async (rows: any[]) => {
|
||||
if (confirm(`Delete ${rows.length} record(s)? This action cannot be undone.`)) {
|
||||
try {
|
||||
@@ -279,6 +287,8 @@ onMounted(async () => {
|
||||
@edit="handleEdit"
|
||||
@delete="() => handleDelete([currentRecord])"
|
||||
@back="handleBack"
|
||||
@navigate="handleNavigate"
|
||||
@create-related="handleCreateRelated"
|
||||
/>
|
||||
|
||||
<!-- Edit View -->
|
||||
|
||||
@@ -95,6 +95,14 @@ const handleBack = () => {
|
||||
router.push(`/app/objects/${objectApiName.value}/`)
|
||||
}
|
||||
|
||||
const handleNavigate = (relatedObjectApiName: string, relatedRecordId: string) => {
|
||||
router.push(`/app/objects/${relatedObjectApiName}/${relatedRecordId}/detail`)
|
||||
}
|
||||
|
||||
const handleCreateRelated = (relatedObjectApiName: string, _parentId: string) => {
|
||||
router.push(`/app/objects/${relatedObjectApiName}/new`)
|
||||
}
|
||||
|
||||
const handleDelete = async (rows: any[]) => {
|
||||
if (confirm(`Delete ${rows.length} record(s)? This action cannot be undone.`)) {
|
||||
try {
|
||||
@@ -212,6 +220,8 @@ onMounted(async () => {
|
||||
@edit="handleEdit"
|
||||
@delete="() => handleDelete([currentRecord])"
|
||||
@back="handleBack"
|
||||
@navigate="handleNavigate"
|
||||
@create-related="handleCreateRelated"
|
||||
/>
|
||||
|
||||
<!-- Edit View -->
|
||||
|
||||
@@ -132,6 +132,8 @@
|
||||
<PageLayoutEditor
|
||||
:fields="object.fields"
|
||||
:initial-layout="(selectedLayout.layoutConfig || selectedLayout.layout_config)?.fields || []"
|
||||
:related-lists="object.relatedLists || []"
|
||||
:initial-related-lists="(selectedLayout.layoutConfig || selectedLayout.layout_config)?.relatedLists || []"
|
||||
:layout-name="selectedLayout.name"
|
||||
@save="handleSaveLayout"
|
||||
/>
|
||||
@@ -203,7 +205,7 @@ const handleCreateLayout = async () => {
|
||||
name,
|
||||
objectId: object.value.id,
|
||||
isDefault: layouts.value.length === 0,
|
||||
layoutConfig: { fields: [] },
|
||||
layoutConfig: { fields: [], relatedLists: [] },
|
||||
})
|
||||
|
||||
layouts.value.push(newLayout)
|
||||
@@ -219,12 +221,12 @@ const handleSelectLayout = (layout: PageLayout) => {
|
||||
selectedLayout.value = layout
|
||||
}
|
||||
|
||||
const handleSaveLayout = async (fields: FieldLayoutItem[]) => {
|
||||
const handleSaveLayout = async (layoutConfig: { fields: FieldLayoutItem[]; relatedLists: string[] }) => {
|
||||
if (!selectedLayout.value) return
|
||||
|
||||
try {
|
||||
const updated = await updatePageLayout(selectedLayout.value.id, {
|
||||
layoutConfig: { fields },
|
||||
layoutConfig,
|
||||
})
|
||||
|
||||
// Update the layout in the list
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface FieldLayoutItem {
|
||||
|
||||
export interface PageLayoutConfig {
|
||||
fields: FieldLayoutItem[];
|
||||
relatedLists?: string[];
|
||||
}
|
||||
|
||||
export interface PageLayout {
|
||||
|
||||
Reference in New Issue
Block a user