diff --git a/backend/src/object/field-mapper.service.ts b/backend/src/object/field-mapper.service.ts index 5043ee8..3eb1d94 100644 --- a/backend/src/object/field-mapper.service.ts +++ b/backend/src/object/field-mapper.service.ts @@ -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, + })), }; } diff --git a/backend/src/object/models/dynamic-model.factory.ts b/backend/src/object/models/dynamic-model.factory.ts index 046a03b..3ac480a 100644 --- a/backend/src/object/models/dynamic-model.factory.ts +++ b/backend/src/object/models/dynamic-model.factory.ts @@ -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`; } } diff --git a/backend/src/object/models/model.service.ts b/backend/src/object/models/model.service.ts index 6b87979..96a6100 100644 --- a/backend/src/object/models/model.service.ts +++ b/backend/src/object/models/model.service.ts @@ -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}`); diff --git a/backend/src/object/object.service.ts b/backend/src/object/object.service.ts index 26fec40..ef8187a 100644 --- a/backend/src/object/object.service.ts +++ b/backend/src/object/object.service.ts @@ -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,16 +580,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(); @@ -589,6 +605,79 @@ export class ObjectService { return filteredRecord; } + private async getRelatedListDefinitions( + tenantId: string, + objectApiName: string, + ): Promise> { + 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(); + 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>( + (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, diff --git a/backend/src/page-layout/dto/page-layout.dto.ts b/backend/src/page-layout/dto/page-layout.dto.ts index e954790..17a8b5d 100644 --- a/backend/src/page-layout/dto/page-layout.dto.ts +++ b/backend/src/page-layout/dto/page-layout.dto.ts @@ -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() diff --git a/frontend/components/PageLayoutEditor.vue b/frontend/components/PageLayoutEditor.vue index 97ac1f8..a8a8d05 100644 --- a/frontend/components/PageLayoutEditor.vue +++ b/frontend/components/PageLayoutEditor.vue @@ -28,22 +28,44 @@ -
-

Available Fields

-

Click or drag to add field to grid

-