Merge pull request #2 from phyroslam/codex/add-dynamic-related-lists-to-detail-views

Add dynamic tenant-level related lists and page layout selection
This commit is contained in:
phyroslam
2026-01-08 15:16:17 -08:00
committed by GitHub
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

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