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;
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,
})),
};
}

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;
}
@@ -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`;
}
}

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)
await this.createModelForObject(tenantId, objectMetadata);
this.logger.log(`Registered model for ${objectApiName} in tenant ${tenantId}`);

View File

@@ -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,

View File

@@ -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()

View File

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

View File

@@ -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"

View File

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

View File

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

View File

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

View File

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

View File

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