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:
@@ -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}`);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user