3 Commits

Author SHA1 Message Date
Francisco Gaona
f4143ab106 WIP - Fix objection and model registry 2025-12-27 06:08:25 +01:00
Francisco Gaona
516e132611 WIP - move docs 2025-12-24 21:46:05 +01:00
Francisco Gaona
c5305490c1 WIP - use objection and working lookup field to owner 2025-12-24 21:43:58 +01:00
42 changed files with 600 additions and 114 deletions

View File

@@ -125,6 +125,7 @@ model FieldDefinition {
isSystem Boolean @default(false)
isCustom Boolean @default(true)
displayOrder Int @default(0)
uiMetadata Json? @map("ui_metadata")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

View File

@@ -51,13 +51,29 @@ export class FieldMapperService {
* Convert a field definition from the database to a frontend-friendly FieldConfig
*/
mapFieldToDTO(field: any): FieldConfigDTO {
const uiMetadata = field.uiMetadata || {};
// Parse ui_metadata if it's a JSON string or object
let uiMetadata: any = {};
const metadataField = field.ui_metadata || field.uiMetadata;
if (metadataField) {
if (typeof metadataField === 'string') {
try {
uiMetadata = JSON.parse(metadataField);
} catch (e) {
uiMetadata = {};
}
} else {
uiMetadata = metadataField;
}
}
const frontendType = this.mapFieldType(field.type);
const isLookupField = frontendType === 'belongsTo' || field.type.toLowerCase().includes('lookup');
return {
id: field.id,
apiName: field.apiName,
label: field.label,
type: this.mapFieldType(field.type),
type: frontendType,
// Display properties
placeholder: uiMetadata.placeholder || field.description,
@@ -82,7 +98,10 @@ export class FieldMapperService {
step: uiMetadata.step,
accept: uiMetadata.accept,
relationObject: field.referenceObject,
relationDisplayField: uiMetadata.relationDisplayField,
// For lookup fields, provide default display field if not specified
relationDisplayField: isLookupField
? (uiMetadata.relationDisplayField || 'name')
: uiMetadata.relationDisplayField,
// Formatting
format: uiMetadata.format,

View File

@@ -30,8 +30,13 @@ export interface ObjectMetadata {
export class DynamicModelFactory {
/**
* Create a dynamic model class from object metadata
* @param meta Object metadata
* @param getModel Function to retrieve model classes from registry
*/
static createModel(meta: ObjectMetadata): ModelClass<any> {
static createModel(
meta: ObjectMetadata,
getModel?: (apiName: string) => ModelClass<any>,
): ModelClass<any> {
const { tableName, fields, apiName, relations = [] } = meta;
// Build JSON schema properties
@@ -57,12 +62,16 @@ export class DynamicModelFactory {
}
}
// Build relation mappings
const relationMappings: RelationMappings = {};
for (const rel of relations) {
// Relations are resolved dynamically, skipping for now
// Will be handled by ModelRegistry.getModel()
}
// Build relation mappings from lookup fields
const lookupFields = fields.filter(f => f.type === 'LOOKUP' && f.referenceObject);
// Store lookup fields metadata for later use
const lookupFieldsInfo = lookupFields.map(f => ({
apiName: f.apiName,
relationName: f.apiName.replace(/Id$/, '').toLowerCase(),
referenceObject: f.referenceObject,
targetTable: this.getTableName(f.referenceObject),
}));
// Create the dynamic model class extending Model directly
class DynamicModel extends Model {
@@ -76,8 +85,41 @@ export class DynamicModelFactory {
static tableName = tableName;
static objectApiName = apiName;
static lookupFields = lookupFieldsInfo;
static relationMappings = relationMappings;
static get relationMappings(): RelationMappings {
const mappings: RelationMappings = {};
// Build relation mappings from lookup fields
for (const lookupInfo of lookupFieldsInfo) {
// Use getModel function if provided, otherwise use string reference
let modelClass: any = lookupInfo.referenceObject;
if (getModel) {
const resolvedModel = getModel(lookupInfo.referenceObject);
// Only use resolved model if it exists, otherwise skip this relation
// It will be resolved later when the model is registered
if (resolvedModel) {
modelClass = resolvedModel;
} else {
// Skip this relation if model not found yet
continue;
}
}
mappings[lookupInfo.relationName] = {
relation: Model.BelongsToOneRelation,
modelClass,
join: {
from: `${tableName}.${lookupInfo.apiName}`,
to: `${lookupInfo.targetTable}.id`,
},
};
}
return mappings;
}
static get jsonSchema() {
return {
@@ -159,4 +201,16 @@ export class DynamicModelFactory {
return { type: 'string' };
}
}
/**
* Get table name from object API name
*/
private static getTableName(objectApiName: string): string {
// Convert PascalCase/camelCase to snake_case and pluralize
const snakeCase = objectApiName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`;
}
}

View File

@@ -42,7 +42,12 @@ export class ModelRegistry {
createAndRegisterModel(
metadata: ObjectMetadata,
): ModelClass<BaseModel> {
const model = DynamicModelFactory.createModel(metadata);
// Create model with a getModel function that resolves from this registry
// Returns undefined if model not found (for models not yet registered)
const model = DynamicModelFactory.createModel(
metadata,
(apiName: string) => this.registry.get(apiName),
);
this.registerModel(metadata.apiName, model);
return model;
}

View File

@@ -5,6 +5,7 @@ import { BaseModel } from './base.model';
import { ModelRegistry } from './model.registry';
import { ObjectMetadata } from './dynamic-model.factory';
import { TenantDatabaseService } from '../../tenant/tenant-database.service';
import { UserModel, RoleModel, PermissionModel } from './system-models';
/**
* Service to manage dynamic models for a specific tenant
@@ -21,11 +22,30 @@ export class ModelService {
*/
getTenantRegistry(tenantId: string): ModelRegistry {
if (!this.tenantRegistries.has(tenantId)) {
this.tenantRegistries.set(tenantId, new ModelRegistry());
const registry = new ModelRegistry();
// Register system models that are defined as static Objection models
this.registerSystemModels(registry);
this.tenantRegistries.set(tenantId, registry);
}
return this.tenantRegistries.get(tenantId)!;
}
/**
* Register static system models in the registry
* Uses simplified models without complex relationMappings to avoid modelPath issues
*/
private registerSystemModels(registry: ModelRegistry): void {
// Register system models by their API name (used in referenceObject fields)
// These are simplified versions without relationMappings to avoid dependency issues
registry.registerModel('User', UserModel as any);
registry.registerModel('Role', RoleModel as any);
registry.registerModel('Permission', PermissionModel as any);
this.logger.debug('Registered system models: User, Role, Permission');
}
/**
* Create and register a model for a tenant
*/
@@ -60,6 +80,24 @@ export class ModelService {
): Promise<ModelClass<BaseModel>> {
const knex = await this.tenantDbService.getTenantKnexById(tenantId);
const model = this.getModel(tenantId, objectApiName);
// Bind knex to the model and also to all models in the registry
// This ensures system models also have knex bound when they're used in relations
const registry = this.getTenantRegistry(tenantId);
const allModels = registry.getAllModelNames();
// Bind knex to all models to ensure relations work
for (const modelName of allModels) {
try {
const m = registry.getModel(modelName);
if (m && !m.knex()) {
m.knex(knex);
}
} catch (error) {
// Ignore errors for models that don't need binding
}
}
return model.bindKnex(knex);
}
@@ -78,4 +116,69 @@ export class ModelService {
const registry = this.getTenantRegistry(tenantId);
return registry.getAllModelNames();
}
/**
* Ensure a model is registered with all its dependencies.
* This method handles recursive model creation for related objects.
*
* @param tenantId - The tenant ID
* @param objectApiName - The object API name to ensure registration for
* @param fetchMetadata - Callback function to fetch object metadata (provided by ObjectService)
* @param visited - Set to track visited models and prevent infinite loops
*/
async ensureModelWithDependencies(
tenantId: string,
objectApiName: string,
fetchMetadata: (apiName: string) => Promise<ObjectMetadata>,
visited: Set<string> = new Set(),
): Promise<void> {
// Prevent infinite recursion
if (visited.has(objectApiName)) {
return;
}
visited.add(objectApiName);
// Check if model already exists
if (this.hasModel(tenantId, objectApiName)) {
return;
}
try {
// Fetch the object metadata
const objectMetadata = await fetchMetadata(objectApiName);
// Extract lookup fields to find dependencies
const lookupFields = objectMetadata.fields.filter(
f => f.type === 'LOOKUP' && f.referenceObject
);
// Recursively ensure all dependent models are registered first
for (const field of lookupFields) {
if (field.referenceObject) {
try {
await this.ensureModelWithDependencies(
tenantId,
field.referenceObject,
fetchMetadata,
visited,
);
} catch (error) {
// If related object doesn't exist (e.g., system tables), skip it
this.logger.debug(
`Skipping registration of related model ${field.referenceObject}: ${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}`);
} catch (error) {
this.logger.warn(
`Failed to ensure model for ${objectApiName}: ${error.message}`
);
throw error;
}
}
}

View File

@@ -0,0 +1,85 @@
import { Model } from 'objection';
/**
* Simplified User model for use in dynamic object relations
* This version doesn't include complex relationMappings to avoid modelPath issues
*/
export class UserModel extends Model {
static tableName = 'users';
static objectApiName = 'User';
id!: string;
email!: string;
firstName?: string;
lastName?: string;
name?: string;
isActive!: boolean;
createdAt!: Date;
updatedAt!: Date;
static get jsonSchema() {
return {
type: 'object',
required: ['email'],
properties: {
id: { type: 'string' },
email: { type: 'string', format: 'email' },
firstName: { type: 'string' },
lastName: { type: 'string' },
name: { type: 'string' },
isActive: { type: 'boolean' },
},
};
}
// No relationMappings to avoid modelPath resolution issues
// These simplified models are only used for lookup relations from dynamic models
}
/**
* Simplified Role model for use in dynamic object relations
*/
export class RoleModel extends Model {
static tableName = 'roles';
static objectApiName = 'Role';
id!: string;
name!: string;
description?: string;
static get jsonSchema() {
return {
type: 'object',
required: ['name'],
properties: {
id: { type: 'string' },
name: { type: 'string' },
description: { type: 'string' },
},
};
}
}
/**
* Simplified Permission model for use in dynamic object relations
*/
export class PermissionModel extends Model {
static tableName = 'permissions';
static objectApiName = 'Permission';
id!: string;
name!: string;
description?: string;
static get jsonSchema() {
return {
type: 'object',
required: ['name'],
properties: {
id: { type: 'string' },
name: { type: 'string' },
description: { type: 'string' },
},
};
}
}

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { CustomMigrationService } from '../migration/custom-migration.service';
import { ModelService } from './models/model.service';
@@ -6,6 +6,8 @@ import { ObjectMetadata } from './models/dynamic-model.factory';
@Injectable()
export class ObjectService {
private readonly logger = new Logger(ObjectService.name);
constructor(
private tenantDbService: TenantDatabaseService,
private customMigrationService: CustomMigrationService,
@@ -107,14 +109,14 @@ export class ObjectService {
description: 'The user who owns this record',
isRequired: false, // Auto-set by system
isUnique: false,
referenceObject: null,
referenceObject: 'User',
isSystem: true,
isCustom: false,
},
{
apiName: 'name',
label: 'Name',
type: 'TEXT',
type: 'STRING',
description: 'The primary name field for this record',
isRequired: false, // Optional field
isUnique: false,
@@ -156,13 +158,22 @@ export class ObjectService {
.first();
if (!existingField) {
await knex('field_definitions').insert({
const fieldData: any = {
id: knex.raw('(UUID())'),
objectDefinitionId: objectDef.id,
...field,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
};
// For lookup fields, set ui_metadata with relationDisplayField
if (field.type === 'LOOKUP') {
fieldData.ui_metadata = JSON.stringify({
relationDisplayField: 'name',
});
}
await knex('field_definitions').insert(fieldData);
}
}
@@ -226,6 +237,8 @@ export class ObjectService {
isRequired?: boolean;
isUnique?: boolean;
referenceObject?: string;
relationObject?: string;
relationDisplayField?: string;
defaultValue?: string;
},
) {
@@ -233,13 +246,35 @@ export class ObjectService {
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const obj = await this.getObjectDefinition(tenantId, objectApiName);
const [id] = await knex('field_definitions').insert({
// Convert frontend type to database type
const dbFieldType = this.convertFrontendFieldType(data.type);
// Use relationObject if provided (alias for referenceObject)
const referenceObject = data.referenceObject || data.relationObject;
const fieldData: any = {
id: knex.raw('(UUID())'),
objectDefinitionId: obj.id,
...data,
apiName: data.apiName,
label: data.label,
type: dbFieldType,
description: data.description,
isRequired: data.isRequired ?? false,
isUnique: data.isUnique ?? false,
referenceObject: referenceObject,
defaultValue: data.defaultValue,
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
});
};
// Store relationDisplayField in UI metadata if provided
if (data.relationDisplayField) {
fieldData.ui_metadata = JSON.stringify({
relationDisplayField: data.relationDisplayField,
});
}
const [id] = await knex('field_definitions').insert(fieldData);
return knex('field_definitions').where({ id }).first();
}
@@ -279,6 +314,100 @@ export class ObjectService {
};
}
/**
* Convert frontend field type to database field type
*/
private convertFrontendFieldType(frontendType: string): string {
const typeMap: Record<string, string> = {
'text': 'TEXT',
'textarea': 'LONG_TEXT',
'password': 'TEXT',
'email': 'EMAIL',
'number': 'NUMBER',
'currency': 'CURRENCY',
'percent': 'PERCENT',
'select': 'PICKLIST',
'multiSelect': 'MULTI_PICKLIST',
'boolean': 'BOOLEAN',
'date': 'DATE',
'datetime': 'DATE_TIME',
'time': 'TIME',
'url': 'URL',
'color': 'TEXT',
'json': 'JSON',
'belongsTo': 'LOOKUP',
'hasMany': 'LOOKUP',
'manyToMany': 'LOOKUP',
'markdown': 'LONG_TEXT',
'code': 'LONG_TEXT',
'file': 'FILE',
'image': 'IMAGE',
};
return typeMap[frontendType] || 'TEXT';
}
/**
* Ensure a model is registered for the given object.
* Delegates to ModelService which handles creating the model and all its dependencies.
*/
private async ensureModelRegistered(
tenantId: string,
objectApiName: string,
): Promise<void> {
// Provide a metadata fetcher function that the ModelService can use
const fetchMetadata = async (apiName: string): Promise<ObjectMetadata> => {
const objectDef = await this.getObjectDefinition(tenantId, apiName);
const tableName = this.getTableName(apiName);
// Build relations from lookup fields, but only for models that exist
const lookupFields = objectDef.fields.filter((f: any) =>
f.type === 'LOOKUP' && f.referenceObject
);
// Filter to only include relations where we can successfully resolve the target
const validRelations: any[] = [];
for (const field of lookupFields) {
// Check if the referenced object will be available
// We'll let the recursive registration attempt it, but won't include failed ones
validRelations.push({
name: field.apiName.replace(/Id$/, '').toLowerCase(),
type: 'belongsTo' as const,
targetObjectApiName: field.referenceObject,
fromColumn: field.apiName,
toColumn: 'id',
});
}
return {
apiName,
tableName,
fields: objectDef.fields.map((f: any) => ({
apiName: f.apiName,
label: f.label,
type: f.type,
isRequired: f.isRequired,
isUnique: f.isUnique,
referenceObject: f.referenceObject,
})),
relations: validRelations,
};
};
// Let the ModelService handle recursive model creation
try {
await this.modelService.ensureModelWithDependencies(
tenantId,
objectApiName,
fetchMetadata,
);
} catch (error) {
this.logger.warn(
`Failed to ensure model for ${objectApiName}: ${error.message}. Will fall back to manual hydration.`,
);
}
}
// Runtime endpoints - CRUD operations
async getRecords(
tenantId: string,
@@ -289,11 +418,14 @@ export class ObjectService {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Verify object exists
await this.getObjectDefinition(tenantId, objectApiName);
// Verify object exists and get field definitions
const objectDef = await this.getObjectDefinition(tenantId, objectApiName);
const tableName = this.getTableName(objectApiName);
// Ensure model is registered before attempting to use it
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// Try to use the Objection model if available
try {
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
@@ -301,6 +433,23 @@ export class ObjectService {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
let query = boundModel.query();
// Build graph expression for lookup fields
const lookupFields = objectDef.fields?.filter(f =>
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(', ');
if (relationExpression) {
query = query.withGraphFetched(`[${relationExpression}]`);
}
}
// Add ownership filter if ownerId field exists
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner) {
@@ -315,15 +464,16 @@ export class ObjectService {
return query.select('*');
}
} catch (error) {
console.warn(`Could not use Objection model for ${objectApiName}:`, error.message);
this.logger.warn(`Could not use Objection model for ${objectApiName}, falling back to manual join: ${error.message}`);
}
// Fallback to raw Knex
// Fallback to manual data hydration
let query = knex(tableName);
// Add ownership filter if ownerId field exists
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner) {
query = query.where({ ownerId: userId });
query = query.where({ [`${tableName}.ownerId`]: userId });
}
// Apply additional filters
@@ -331,7 +481,49 @@ export class ObjectService {
query = query.where(filters);
}
return query.select('*');
// Get base records
const records = await query.select(`${tableName}.*`);
// Fetch and attach related records for lookup fields
const lookupFields = objectDef.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject
) || [];
if (lookupFields.length > 0 && records.length > 0) {
for (const field of lookupFields) {
const relationName = field.apiName.replace(/Id$/, '').toLowerCase();
const relatedTable = this.getTableName(field.referenceObject);
// Get unique IDs to fetch
const relatedIds = [...new Set(
records
.map(r => r[field.apiName])
.filter(Boolean)
)];
if (relatedIds.length > 0) {
// Fetch all related records in one query
const relatedRecords = await knex(relatedTable)
.whereIn('id', relatedIds)
.select('*');
// Create a map for quick lookup
const relatedMap = new Map(
relatedRecords.map(r => [r.id, r])
);
// Attach related records to main records
for (const record of records) {
const relatedId = record[field.apiName];
if (relatedId && relatedMap.has(relatedId)) {
record[relationName] = relatedMap.get(relatedId);
}
}
}
}
}
return records;
}
async getRecord(
@@ -343,11 +535,14 @@ export class ObjectService {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Verify object exists
await this.getObjectDefinition(tenantId, objectApiName);
// Verify object exists and get field definitions
const objectDef = await this.getObjectDefinition(tenantId, objectApiName);
const tableName = this.getTableName(objectApiName);
// Ensure model is registered before attempting to use it
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// Try to use the Objection model if available
try {
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
@@ -355,6 +550,23 @@ export class ObjectService {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
let query = boundModel.query().where({ id: recordId });
// Build graph expression for lookup fields
const lookupFields = objectDef.fields?.filter(f =>
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(', ');
if (relationExpression) {
query = query.withGraphFetched(`[${relationExpression}]`);
}
}
// Add ownership filter if ownerId field exists
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner) {
@@ -368,23 +580,48 @@ export class ObjectService {
return record;
}
} catch (error) {
console.warn(`Could not use Objection model for ${objectApiName}:`, error.message);
this.logger.warn(`Could not use Objection model for ${objectApiName}, falling back to manual join: ${error.message}`);
}
// Fallback to raw Knex
let query = knex(tableName).where({ id: recordId });
// Fallback to manual data hydration
let query = knex(tableName).where({ [`${tableName}.id`]: recordId });
// Add ownership filter if ownerId field exists
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner) {
query = query.where({ ownerId: userId });
query = query.where({ [`${tableName}.ownerId`]: userId });
}
const record = await query.first();
if (!record) {
throw new NotFoundException('Record not found');
}
// Fetch and attach related records for lookup fields
const lookupFields = objectDef.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject
) || [];
if (lookupFields.length > 0) {
for (const field of lookupFields) {
const relationName = field.apiName.replace(/Id$/, '').toLowerCase();
const relatedTable = this.getTableName(field.referenceObject);
const relatedId = record[field.apiName];
if (relatedId) {
// Fetch the related record
const relatedRecord = await knex(relatedTable)
.where({ id: relatedId })
.first();
if (relatedRecord) {
record[relationName] = relatedRecord;
}
}
}
}
return record;
}
@@ -400,6 +637,9 @@ export class ObjectService {
// Verify object exists
await this.getObjectDefinition(tenantId, objectApiName);
// Ensure model is registered before attempting to use it
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// Try to use the Objection model if available
try {
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
@@ -451,6 +691,9 @@ export class ObjectService {
const tableName = this.getTableName(objectApiName);
// Ensure model is registered before attempting to use it
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// Try to use the Objection model if available
try {
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);
@@ -492,6 +735,9 @@ export class ObjectService {
const tableName = this.getTableName(objectApiName);
// Ensure model is registered before attempting to use it
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
// Try to use the Objection model if available
try {
const Model = this.modelService.getModel(resolvedTenantId, objectApiName);

View File

@@ -29,7 +29,8 @@ export class SetupObjectController {
@TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string,
) {
return this.objectService.getObjectDefinition(tenantId, objectApiName);
const objectDef = await this.objectService.getObjectDefinition(tenantId, objectApiName);
return this.fieldMapperService.mapObjectDefinitionToDTO(objectDef);
}
@Get(':objectApiName/ui-config')
@@ -58,10 +59,12 @@ export class SetupObjectController {
@Param('objectApiName') objectApiName: string,
@Body() data: any,
) {
return this.objectService.createFieldDefinition(
const field = await this.objectService.createFieldDefinition(
tenantId,
objectApiName,
data,
);
// Map the created field to frontend format
return this.fieldMapperService.mapFieldToDTO(field);
}
}

View File

@@ -14,6 +14,7 @@
v-if="fieldItem.field"
:field="fieldItem.field"
:model-value="modelValue?.[fieldItem.field.apiName]"
:record-data="modelValue"
:mode="readonly ? VM.DETAIL : VM.EDIT"
@update:model-value="handleFieldUpdate(fieldItem.field.apiName, $event)"
/>
@@ -30,6 +31,7 @@
<FieldRenderer
:field="field"
:model-value="modelValue?.[field.apiName]"
:record-data="modelValue"
:mode="readonly ? VM.DETAIL : VM.EDIT"
@update:model-value="handleFieldUpdate(field.apiName, $event)"
/>

View File

@@ -30,10 +30,6 @@ const emit = defineEmits<{
const { api } = useApi()
// For relationship fields, store the related record for display
const relatedRecord = ref<any | null>(null)
const loadingRelated = ref(false)
const value = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val),
@@ -49,80 +45,36 @@ const isRelationshipField = computed(() => {
return [FieldType.BELONGS_TO].includes(props.field.type)
})
// Get relation object name (e.g., 'tenants' -> singular 'tenant')
// Get relation object name from field apiName (e.g., 'ownerId' -> 'owner')
const getRelationPropertyName = () => {
const relationObject = props.field.relationObject || props.field.apiName.replace('Id', '')
// Convert plural to singular for property name (e.g., 'tenants' -> 'tenant')
return relationObject.endsWith('s') ? relationObject.slice(0, -1) : relationObject
}
// Fetch related record for display
const fetchRelatedRecord = async () => {
if (!isRelationshipField.value || !props.modelValue) return
const relationObject = props.field.relationObject || props.field.apiName.replace('Id', '')
const displayField = props.field.relationDisplayField || 'name'
loadingRelated.value = true
try {
const record = await api.get(`${props.baseUrl}/${relationObject}/${props.modelValue}`)
relatedRecord.value = record
} catch (err) {
console.error('Error fetching related record:', err)
relatedRecord.value = null
} finally {
loadingRelated.value = false
}
// Backend attaches related object using field apiName without 'Id' suffix, lowercase
// e.g., ownerId -> owner, accountId -> account
return props.field.apiName.replace(/Id$/, '').toLowerCase()
}
// Display value for relationship fields
const relationshipDisplayValue = computed(() => {
if (!isRelationshipField.value) return props.modelValue || '-'
// First, check if the parent record data includes the related object
// This happens when backend uses .withGraphFetched()
if (props.recordData) {
const relationPropertyName = getRelationPropertyName()
const relatedObject = props.recordData[relationPropertyName]
if (relatedObject && typeof relatedObject === 'object') {
const displayField = props.field.relationDisplayField || 'name'
return relatedObject[displayField] || relatedObject.id || props.modelValue
}
}
// Otherwise use the fetched related record
if (relatedRecord.value) {
const displayField = props.field.relationDisplayField || 'name'
return relatedRecord.value[displayField] || relatedRecord.value.id
}
// Show loading state
if (loadingRelated.value) {
return 'Loading...'
}
// Fallback to ID
// If no related object found in recordData, just show the ID
// (The fetch mechanism is removed to avoid N+1 queries)
return props.modelValue || '-'
})
// Watch for changes in modelValue for relationship fields
watch(() => props.modelValue, () => {
if (isRelationshipField.value && (isDetailMode.value || isListMode.value)) {
fetchRelatedRecord()
}
})
// Load related record on mount if needed
onMounted(() => {
if (isRelationshipField.value && props.modelValue && (isDetailMode.value || isListMode.value)) {
fetchRelatedRecord()
}
})
const formatValue = (val: any): string => {
if (val === null || val === undefined) return '-'
switch (props.field.type) {
case FieldType.BELONGS_TO:
return relationshipDisplayValue.value
@@ -168,6 +120,7 @@ const formatValue = (val: any): string => {
{{ formatValue(value) }}
</Badge>
<template v-else>
{{ formatValue(value) }}
</template>
</div>

View File

@@ -56,7 +56,8 @@ const filteredRecords = computed(() => {
const fetchRecords = async () => {
loading.value = true
try {
const response = await api.get(`${props.baseUrl}/${relationObject.value}`)
const endpoint = `${props.baseUrl}/${relationObject.value}/records`
const response = await api.get(endpoint)
records.value = response || []
// If we have a modelValue, find the selected record

View File

@@ -19,10 +19,12 @@ interface Props {
data: any
loading?: boolean
objectId?: string // For fetching page layout
baseUrl?: string
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
baseUrl: '/runtime/objects',
})
const emit = defineEmits<{
@@ -170,6 +172,7 @@ const usePageLayout = computed(() => {
:model-value="data[field.apiName]"
:record-data="data"
:mode="ViewMode.DETAIL"
:base-url="baseUrl"
/>
</div>
</CardContent>
@@ -192,6 +195,7 @@ const usePageLayout = computed(() => {
:model-value="data[field.apiName]"
:record-data="data"
:mode="ViewMode.DETAIL"
:base-url="baseUrl"
/>
</div>
</CardContent>

View File

@@ -19,12 +19,14 @@ interface Props {
loading?: boolean
saving?: boolean
objectId?: string // For fetching page layout
baseUrl?: string
}
const props = withDefaults(defineProps<Props>(), {
data: () => ({}),
loading: false,
saving: false,
baseUrl: '/runtime/objects',
})
const emit = defineEmits<{
@@ -260,6 +262,7 @@ const handleFieldUpdate = (fieldName: string, value: any) => {
:model-value="formData[field.apiName]"
:mode="ViewMode.EDIT"
:error="errors[field.apiName]"
:base-url="baseUrl"
@update:model-value="handleFieldUpdate(field.apiName, $event)"
/>
</div>
@@ -283,6 +286,7 @@ const handleFieldUpdate = (fieldName: string, value: any) => {
:model-value="formData[field.apiName]"
:mode="ViewMode.EDIT"
:error="errors[field.apiName]"
:base-url="baseUrl"
@update:model-value="handleFieldUpdate(field.apiName, $event)"
/>
</div>

View File

@@ -21,12 +21,14 @@ interface Props {
data?: any[]
loading?: boolean
selectable?: boolean
baseUrl?: string
}
const props = withDefaults(defineProps<Props>(), {
data: () => [],
loading: false,
selectable: false,
baseUrl: '/runtime/objects',
})
const emit = defineEmits<{
@@ -207,6 +209,7 @@ const handleAction = (actionId: string) => {
:model-value="row[field.apiName]"
:record-data="row"
:mode="ViewMode.LIST"
:base-url="baseUrl"
/>
</TableCell>
<TableCell @click.stop>

View File

@@ -27,35 +27,35 @@ export const useFields = () => {
type: fieldDef.type,
// Default values
placeholder: fieldDef.uiMetadata?.placeholder || fieldDef.description,
helpText: fieldDef.uiMetadata?.helpText || fieldDef.description,
placeholder: fieldDef.placeholder || fieldDef.description,
helpText: fieldDef.helpText || fieldDef.description,
defaultValue: fieldDef.defaultValue,
// Validation
isRequired: fieldDef.isRequired,
isReadOnly: isAutoGeneratedField || fieldDef.uiMetadata?.isReadOnly,
validationRules: fieldDef.uiMetadata?.validationRules || [],
isReadOnly: isAutoGeneratedField || fieldDef.isReadOnly,
validationRules: fieldDef.validationRules || [],
// View options - only hide system and auto-generated fields by default
showOnList: fieldDef.uiMetadata?.showOnList ?? true,
showOnDetail: fieldDef.uiMetadata?.showOnDetail ?? true,
showOnEdit: fieldDef.uiMetadata?.showOnEdit ?? !shouldHideOnEdit,
sortable: fieldDef.uiMetadata?.sortable ?? true,
showOnList: fieldDef.showOnList ?? true,
showOnDetail: fieldDef.showOnDetail ?? true,
showOnEdit: fieldDef.showOnEdit ?? !shouldHideOnEdit,
sortable: fieldDef.sortable ?? true,
// Field type specific
options: fieldDef.uiMetadata?.options,
rows: fieldDef.uiMetadata?.rows,
min: fieldDef.uiMetadata?.min,
max: fieldDef.uiMetadata?.max,
step: fieldDef.uiMetadata?.step,
accept: fieldDef.uiMetadata?.accept,
relationObject: fieldDef.referenceObject,
relationDisplayField: fieldDef.uiMetadata?.relationDisplayField,
options: fieldDef.options,
rows: fieldDef.rows,
min: fieldDef.min,
max: fieldDef.max,
step: fieldDef.step,
accept: fieldDef.accept,
relationObject: fieldDef.relationObject,
relationDisplayField: fieldDef.relationDisplayField,
// Formatting
format: fieldDef.uiMetadata?.format,
prefix: fieldDef.uiMetadata?.prefix,
suffix: fieldDef.uiMetadata?.suffix,
format: fieldDef.format,
prefix: fieldDef.prefix,
suffix: fieldDef.suffix,
// Advanced
dependsOn: fieldDef.uiMetadata?.dependsOn,

View File

@@ -260,6 +260,7 @@ onMounted(async () => {
:config="listConfig"
:data="records"
:loading="dataLoading"
:base-url="`/runtime/objects`"
selectable
@row-click="handleRowClick"
@create="handleCreate"
@@ -274,6 +275,7 @@ onMounted(async () => {
:data="currentRecord"
:loading="dataLoading"
:object-id="objectDefinition?.id"
:base-url="`/runtime/objects`"
@edit="handleEdit"
@delete="() => handleDelete([currentRecord])"
@back="handleBack"
@@ -287,6 +289,7 @@ onMounted(async () => {
:loading="dataLoading"
:saving="saving"
:object-id="objectDefinition?.id"
:base-url="`/runtime/objects`"
@save="handleSaveRecord"
@cancel="handleCancel"
@back="handleBack"