Add dynamic related lists with page layout support

This commit is contained in:
phyroslam
2026-01-08 15:15:28 -08:00
parent 16907aadf8
commit 9be98e4a09
12 changed files with 287 additions and 35 deletions

View File

@@ -43,6 +43,14 @@ export interface ObjectDefinitionDTO {
description?: string; description?: string;
isSystem: boolean; isSystem: boolean;
fields: FieldConfigDTO[]; fields: FieldConfigDTO[];
relatedLists?: Array<{
title: string;
relationName: string;
objectApiName: string;
fields: FieldConfigDTO[];
canCreate?: boolean;
createRoute?: string;
}>;
} }
@Injectable() @Injectable()
@@ -206,6 +214,17 @@ export class FieldMapperService {
.filter((f: any) => f.isActive !== false) .filter((f: any) => f.isActive !== false)
.sort((a: any, b: any) => (a.displayOrder || 0) - (b.displayOrder || 0)) .sort((a: any, b: any) => (a.displayOrder || 0) - (b.displayOrder || 0))
.map((f: any) => this.mapFieldToDTO(f)), .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,
})),
}; };
} }

View File

@@ -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; return mappings;
} }
@@ -196,6 +237,9 @@ export class DynamicModelFactory {
.replace(/([A-Z])/g, '_$1') .replace(/([A-Z])/g, '_$1')
.toLowerCase() .toLowerCase()
.replace(/^_/, ''); .replace(/^_/, '');
if (snakeCase.endsWith('y')) {
return `${snakeCase.slice(0, -1)}ies`;
}
return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`; return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`;
} }
} }

View File

@@ -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) // Now create and register this model (all dependencies are ready)
await this.createModelForObject(tenantId, objectMetadata); await this.createModelForObject(tenantId, objectMetadata);
this.logger.log(`Registered model for ${objectApiName} in tenant ${tenantId}`); this.logger.log(`Registered model for ${objectApiName} in tenant ${tenantId}`);

View File

@@ -71,10 +71,13 @@ export class ObjectService {
.first(); .first();
} }
const relatedLists = await this.getRelatedListDefinitions(resolvedTenantId, apiName);
return { return {
...obj, ...obj,
fields: normalizedFields, fields: normalizedFields,
app, 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 { return {
apiName, apiName,
tableName, tableName,
@@ -566,16 +580,18 @@ export class ObjectService {
f.type === 'LOOKUP' && f.referenceObject f.type === 'LOOKUP' && f.referenceObject
) || []; ) || [];
if (lookupFields.length > 0) { const relatedLists = await this.getRelatedListDefinitions(resolvedTenantId, objectApiName);
// Build relation expression - use singular lowercase for relation name
const relationExpression = lookupFields const relationNames = [
...lookupFields
.map(f => f.apiName.replace(/Id$/, '').toLowerCase()) .map(f => f.apiName.replace(/Id$/, '').toLowerCase())
.filter(Boolean) .filter(Boolean),
.join(', '); ...relatedLists.map(list => list.relationName),
];
if (relationExpression) {
query = query.withGraphFetched(`[${relationExpression}]`); if (relationNames.length > 0) {
} const relationExpression = relationNames.join(', ');
query = query.withGraphFetched(`[${relationExpression}]`);
} }
const record = await query.first(); const record = await query.first();
@@ -589,6 +605,79 @@ export class ObjectService {
return filteredRecord; 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( async createRecord(
tenantId: string, tenantId: string,
objectApiName: string, objectApiName: string,

View File

@@ -20,6 +20,7 @@ export class CreatePageLayoutDto {
w: number; w: number;
h: number; h: number;
}>; }>;
relatedLists?: string[];
}; };
@IsString() @IsString()
@@ -46,6 +47,7 @@ export class UpdatePageLayoutDto {
w: number; w: number;
h: number; h: number;
}>; }>;
relatedLists?: string[];
}; };
@IsString() @IsString()

View File

@@ -28,22 +28,44 @@
</div> </div>
<!-- Available Fields Sidebar --> <!-- 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">
<h3 class="text-lg font-semibold mb-4">Available Fields</h3> <div>
<p class="text-xs text-muted-foreground mb-4">Click or drag to add field to grid</p> <h3 class="text-lg font-semibold mb-4">Available Fields</h3>
<div class="space-y-2" id="sidebar-fields"> <p class="text-xs text-muted-foreground mb-4">Click or drag to add field to grid</p>
<div <div class="space-y-2" id="sidebar-fields">
v-for="field in availableFields" <div
:key="field.id" v-for="field in availableFields"
class="p-3 border rounded cursor-move bg-white dark:bg-slate-900 hover:border-primary hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors" :key="field.id"
:data-field-id="field.id" class="p-3 border rounded cursor-move bg-white dark:bg-slate-900 hover:border-primary hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
draggable="true" :data-field-id="field.id"
@dragstart="handleDragStart($event, field)" draggable="true"
@click="addFieldToGrid(field)" @dragstart="handleDragStart($event, field)"
> @click="addFieldToGrid(field)"
<div class="font-medium text-sm">{{ field.label }}</div> >
<div class="text-xs text-muted-foreground">{{ field.apiName }}</div> <div class="font-medium text-sm">{{ field.label }}</div>
<div class="text-xs text-muted-foreground">Type: {{ field.type }}</div> <div class="text-xs text-muted-foreground">{{ field.apiName }}</div>
<div class="text-xs text-muted-foreground">Type: {{ field.type }}</div>
</div>
</div>
</div>
<div 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> </div>
@@ -52,26 +74,29 @@
</template> </template>
<script setup lang="ts"> <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 } from 'gridstack'
import 'gridstack/dist/gridstack.min.css' import 'gridstack/dist/gridstack.min.css'
import type { FieldLayoutItem } from '~/types/page-layout' 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' import { Button } from '@/components/ui/button'
const props = defineProps<{ const props = defineProps<{
fields: FieldConfig[] fields: FieldConfig[]
relatedLists?: RelatedListConfig[]
initialLayout?: FieldLayoutItem[] initialLayout?: FieldLayoutItem[]
initialRelatedLists?: string[]
layoutName?: string layoutName?: string
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
save: [layout: FieldLayoutItem[]] save: [layout: { fields: FieldLayoutItem[]; relatedLists: string[] }]
}>() }>()
const gridContainer = ref<HTMLElement | null>(null) const gridContainer = ref<HTMLElement | null>(null)
let grid: GridStack | null = null let grid: GridStack | null = null
const gridItems = ref<Map<string, any>>(new Map()) const gridItems = ref<Map<string, any>>(new Map())
const selectedRelatedLists = ref<string[]>(props.initialRelatedLists || [])
// Fields that are already on the grid // Fields that are already on the grid
const placedFieldIds = ref<Set<string>>(new Set()) const placedFieldIds = ref<Set<string>>(new Set())
@@ -81,6 +106,10 @@ const availableFields = computed(() => {
return props.fields.filter(field => !placedFieldIds.value.has(field.id)) return props.fields.filter(field => !placedFieldIds.value.has(field.id))
}) })
const relatedLists = computed(() => {
return props.relatedLists || []
})
const initGrid = () => { const initGrid = () => {
if (!gridContainer.value) return if (!gridContainer.value) return
@@ -278,7 +307,10 @@ const handleSave = () => {
} }
}) })
emit('save', layout) emit('save', {
fields: layout,
relatedLists: selectedRelatedLists.value,
})
} }
onMounted(() => { onMounted(() => {
@@ -295,6 +327,13 @@ onBeforeUnmount(() => {
watch(() => props.fields, () => { watch(() => props.fields, () => {
updatePlacedFields() updatePlacedFields()
}, { deep: true }) }, { deep: true })
watch(
() => props.initialRelatedLists,
(value) => {
selectedRelatedLists.value = value ? [...value] : []
},
)
</script> </script>
<style> <style>

View File

@@ -87,6 +87,22 @@ const getFieldsBySection = (section: FieldSection) => {
const usePageLayout = computed(() => { const usePageLayout = computed(() => {
return pageLayout.value && pageLayout.value.fields && pageLayout.value.fields.length > 0 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> </script>
<template> <template>
@@ -138,7 +154,7 @@ const usePageLayout = computed(() => {
<Tabs v-else default-value="details" class="space-y-6"> <Tabs v-else default-value="details" class="space-y-6">
<TabsList> <TabsList>
<TabsTrigger value="details">Details</TabsTrigger> <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 Related
</TabsTrigger> </TabsTrigger>
<TabsTrigger v-if="showSharing && data.id" value="sharing"> <TabsTrigger v-if="showSharing && data.id" value="sharing">
@@ -224,9 +240,9 @@ const usePageLayout = computed(() => {
<!-- Related Lists Tab --> <!-- Related Lists Tab -->
<TabsContent value="related" class="space-y-6"> <TabsContent value="related" class="space-y-6">
<div v-if="config.relatedLists && config.relatedLists.length > 0"> <div v-if="visibleRelatedLists.length > 0">
<RelatedList <RelatedList
v-for="relatedList in config.relatedLists" v-for="relatedList in visibleRelatedLists"
:key="relatedList.relationName" :key="relatedList.relationName"
:config="relatedList" :config="relatedList"
:parent-id="data.id" :parent-id="data.id"

View File

@@ -97,6 +97,7 @@ export const useFields = () => {
objectApiName: objectDef.apiName, objectApiName: objectDef.apiName,
mode: 'detail' as ViewMode, mode: 'detail' as ViewMode,
fields, fields,
relatedLists: objectDef.relatedLists || [],
...customConfig, ...customConfig,
} }
} }

View File

@@ -155,6 +155,14 @@ const handleBack = () => {
router.push(`/${objectApiName.value.toLowerCase()}/`) 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[]) => { const handleDelete = async (rows: any[]) => {
if (confirm(`Delete ${rows.length} record(s)? This action cannot be undone.`)) { if (confirm(`Delete ${rows.length} record(s)? This action cannot be undone.`)) {
try { try {
@@ -279,6 +287,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 -->

View File

@@ -95,6 +95,14 @@ const handleBack = () => {
router.push(`/app/objects/${objectApiName.value}/`) 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[]) => { const handleDelete = async (rows: any[]) => {
if (confirm(`Delete ${rows.length} record(s)? This action cannot be undone.`)) { if (confirm(`Delete ${rows.length} record(s)? This action cannot be undone.`)) {
try { try {
@@ -212,6 +220,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 -->

View File

@@ -132,6 +132,8 @@
<PageLayoutEditor <PageLayoutEditor
:fields="object.fields" :fields="object.fields"
:initial-layout="(selectedLayout.layoutConfig || selectedLayout.layout_config)?.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" :layout-name="selectedLayout.name"
@save="handleSaveLayout" @save="handleSaveLayout"
/> />
@@ -203,7 +205,7 @@ const handleCreateLayout = async () => {
name, name,
objectId: object.value.id, objectId: object.value.id,
isDefault: layouts.value.length === 0, isDefault: layouts.value.length === 0,
layoutConfig: { fields: [] }, layoutConfig: { fields: [], relatedLists: [] },
}) })
layouts.value.push(newLayout) layouts.value.push(newLayout)
@@ -219,12 +221,12 @@ const handleSelectLayout = (layout: PageLayout) => {
selectedLayout.value = layout selectedLayout.value = layout
} }
const handleSaveLayout = async (fields: FieldLayoutItem[]) => { const handleSaveLayout = async (layoutConfig: { fields: FieldLayoutItem[]; relatedLists: string[] }) => {
if (!selectedLayout.value) return if (!selectedLayout.value) return
try { try {
const updated = await updatePageLayout(selectedLayout.value.id, { const updated = await updatePageLayout(selectedLayout.value.id, {
layoutConfig: { fields }, layoutConfig,
}) })
// Update the layout in the list // Update the layout in the list

View File

@@ -8,6 +8,7 @@ export interface FieldLayoutItem {
export interface PageLayoutConfig { export interface PageLayoutConfig {
fields: FieldLayoutItem[]; fields: FieldLayoutItem[];
relatedLists?: string[];
} }
export interface PageLayout { export interface PageLayout {