WIP - use objection and working lookup field to owner
This commit is contained in:
@@ -125,6 +125,7 @@ model FieldDefinition {
|
|||||||
isSystem Boolean @default(false)
|
isSystem Boolean @default(false)
|
||||||
isCustom Boolean @default(true)
|
isCustom Boolean @default(true)
|
||||||
displayOrder Int @default(0)
|
displayOrder Int @default(0)
|
||||||
|
uiMetadata Json? @map("ui_metadata")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
|||||||
@@ -51,13 +51,29 @@ export class FieldMapperService {
|
|||||||
* Convert a field definition from the database to a frontend-friendly FieldConfig
|
* Convert a field definition from the database to a frontend-friendly FieldConfig
|
||||||
*/
|
*/
|
||||||
mapFieldToDTO(field: any): FieldConfigDTO {
|
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 {
|
return {
|
||||||
id: field.id,
|
id: field.id,
|
||||||
apiName: field.apiName,
|
apiName: field.apiName,
|
||||||
label: field.label,
|
label: field.label,
|
||||||
type: this.mapFieldType(field.type),
|
type: frontendType,
|
||||||
|
|
||||||
// Display properties
|
// Display properties
|
||||||
placeholder: uiMetadata.placeholder || field.description,
|
placeholder: uiMetadata.placeholder || field.description,
|
||||||
@@ -82,7 +98,10 @@ export class FieldMapperService {
|
|||||||
step: uiMetadata.step,
|
step: uiMetadata.step,
|
||||||
accept: uiMetadata.accept,
|
accept: uiMetadata.accept,
|
||||||
relationObject: field.referenceObject,
|
relationObject: field.referenceObject,
|
||||||
relationDisplayField: uiMetadata.relationDisplayField,
|
// For lookup fields, provide default display field if not specified
|
||||||
|
relationDisplayField: isLookupField
|
||||||
|
? (uiMetadata.relationDisplayField || 'name')
|
||||||
|
: uiMetadata.relationDisplayField,
|
||||||
|
|
||||||
// Formatting
|
// Formatting
|
||||||
format: uiMetadata.format,
|
format: uiMetadata.format,
|
||||||
|
|||||||
@@ -30,8 +30,13 @@ export interface ObjectMetadata {
|
|||||||
export class DynamicModelFactory {
|
export class DynamicModelFactory {
|
||||||
/**
|
/**
|
||||||
* Create a dynamic model class from object metadata
|
* 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;
|
const { tableName, fields, apiName, relations = [] } = meta;
|
||||||
|
|
||||||
// Build JSON schema properties
|
// Build JSON schema properties
|
||||||
@@ -57,12 +62,16 @@ export class DynamicModelFactory {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build relation mappings
|
// Build relation mappings from lookup fields
|
||||||
const relationMappings: RelationMappings = {};
|
const lookupFields = fields.filter(f => f.type === 'LOOKUP' && f.referenceObject);
|
||||||
for (const rel of relations) {
|
|
||||||
// Relations are resolved dynamically, skipping for now
|
// Store lookup fields metadata for later use
|
||||||
// Will be handled by ModelRegistry.getModel()
|
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
|
// Create the dynamic model class extending Model directly
|
||||||
class DynamicModel extends Model {
|
class DynamicModel extends Model {
|
||||||
@@ -76,8 +85,41 @@ export class DynamicModelFactory {
|
|||||||
static tableName = tableName;
|
static tableName = tableName;
|
||||||
|
|
||||||
static objectApiName = apiName;
|
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() {
|
static get jsonSchema() {
|
||||||
return {
|
return {
|
||||||
@@ -159,4 +201,16 @@ export class DynamicModelFactory {
|
|||||||
return { type: 'string' };
|
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`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,12 @@ export class ModelRegistry {
|
|||||||
createAndRegisterModel(
|
createAndRegisterModel(
|
||||||
metadata: ObjectMetadata,
|
metadata: ObjectMetadata,
|
||||||
): ModelClass<BaseModel> {
|
): 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);
|
this.registerModel(metadata.apiName, model);
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { TenantDatabaseService } from '../tenant/tenant-database.service';
|
||||||
import { CustomMigrationService } from '../migration/custom-migration.service';
|
import { CustomMigrationService } from '../migration/custom-migration.service';
|
||||||
import { ModelService } from './models/model.service';
|
import { ModelService } from './models/model.service';
|
||||||
@@ -6,6 +6,8 @@ import { ObjectMetadata } from './models/dynamic-model.factory';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ObjectService {
|
export class ObjectService {
|
||||||
|
private readonly logger = new Logger(ObjectService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private tenantDbService: TenantDatabaseService,
|
private tenantDbService: TenantDatabaseService,
|
||||||
private customMigrationService: CustomMigrationService,
|
private customMigrationService: CustomMigrationService,
|
||||||
@@ -107,14 +109,14 @@ export class ObjectService {
|
|||||||
description: 'The user who owns this record',
|
description: 'The user who owns this record',
|
||||||
isRequired: false, // Auto-set by system
|
isRequired: false, // Auto-set by system
|
||||||
isUnique: false,
|
isUnique: false,
|
||||||
referenceObject: null,
|
referenceObject: 'User',
|
||||||
isSystem: true,
|
isSystem: true,
|
||||||
isCustom: false,
|
isCustom: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
apiName: 'name',
|
apiName: 'name',
|
||||||
label: 'Name',
|
label: 'Name',
|
||||||
type: 'TEXT',
|
type: 'STRING',
|
||||||
description: 'The primary name field for this record',
|
description: 'The primary name field for this record',
|
||||||
isRequired: false, // Optional field
|
isRequired: false, // Optional field
|
||||||
isUnique: false,
|
isUnique: false,
|
||||||
@@ -156,13 +158,22 @@ export class ObjectService {
|
|||||||
.first();
|
.first();
|
||||||
|
|
||||||
if (!existingField) {
|
if (!existingField) {
|
||||||
await knex('field_definitions').insert({
|
const fieldData: any = {
|
||||||
id: knex.raw('(UUID())'),
|
id: knex.raw('(UUID())'),
|
||||||
objectDefinitionId: objectDef.id,
|
objectDefinitionId: objectDef.id,
|
||||||
...field,
|
...field,
|
||||||
created_at: knex.fn.now(),
|
created_at: knex.fn.now(),
|
||||||
updated_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;
|
isRequired?: boolean;
|
||||||
isUnique?: boolean;
|
isUnique?: boolean;
|
||||||
referenceObject?: string;
|
referenceObject?: string;
|
||||||
|
relationObject?: string;
|
||||||
|
relationDisplayField?: string;
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@@ -233,13 +246,35 @@ export class ObjectService {
|
|||||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
const obj = await this.getObjectDefinition(tenantId, objectApiName);
|
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())'),
|
id: knex.raw('(UUID())'),
|
||||||
objectDefinitionId: obj.id,
|
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(),
|
created_at: knex.fn.now(),
|
||||||
updated_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();
|
return knex('field_definitions').where({ id }).first();
|
||||||
}
|
}
|
||||||
@@ -279,6 +314,39 @@ 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';
|
||||||
|
}
|
||||||
|
|
||||||
// Runtime endpoints - CRUD operations
|
// Runtime endpoints - CRUD operations
|
||||||
async getRecords(
|
async getRecords(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
@@ -289,8 +357,8 @@ export class ObjectService {
|
|||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
// Verify object exists
|
// Verify object exists and get field definitions
|
||||||
await this.getObjectDefinition(tenantId, objectApiName);
|
const objectDef = await this.getObjectDefinition(tenantId, objectApiName);
|
||||||
|
|
||||||
const tableName = this.getTableName(objectApiName);
|
const tableName = this.getTableName(objectApiName);
|
||||||
|
|
||||||
@@ -301,6 +369,23 @@ export class ObjectService {
|
|||||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||||
let query = boundModel.query();
|
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
|
// Add ownership filter if ownerId field exists
|
||||||
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
|
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
|
||||||
if (hasOwner) {
|
if (hasOwner) {
|
||||||
@@ -315,15 +400,16 @@ export class ObjectService {
|
|||||||
return query.select('*');
|
return query.select('*');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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);
|
let query = knex(tableName);
|
||||||
|
|
||||||
|
// Add ownership filter if ownerId field exists
|
||||||
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
|
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
|
||||||
if (hasOwner) {
|
if (hasOwner) {
|
||||||
query = query.where({ ownerId: userId });
|
query = query.where({ [`${tableName}.ownerId`]: userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply additional filters
|
// Apply additional filters
|
||||||
@@ -331,7 +417,49 @@ export class ObjectService {
|
|||||||
query = query.where(filters);
|
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(
|
async getRecord(
|
||||||
@@ -343,8 +471,8 @@ export class ObjectService {
|
|||||||
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
|
||||||
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
|
||||||
|
|
||||||
// Verify object exists
|
// Verify object exists and get field definitions
|
||||||
await this.getObjectDefinition(tenantId, objectApiName);
|
const objectDef = await this.getObjectDefinition(tenantId, objectApiName);
|
||||||
|
|
||||||
const tableName = this.getTableName(objectApiName);
|
const tableName = this.getTableName(objectApiName);
|
||||||
|
|
||||||
@@ -355,6 +483,23 @@ export class ObjectService {
|
|||||||
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
|
||||||
let query = boundModel.query().where({ id: recordId });
|
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
|
// Add ownership filter if ownerId field exists
|
||||||
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
|
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
|
||||||
if (hasOwner) {
|
if (hasOwner) {
|
||||||
@@ -368,23 +513,48 @@ export class ObjectService {
|
|||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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).where({ id: recordId });
|
let query = knex(tableName).where({ [`${tableName}.id`]: recordId });
|
||||||
|
|
||||||
|
// Add ownership filter if ownerId field exists
|
||||||
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
|
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
|
||||||
if (hasOwner) {
|
if (hasOwner) {
|
||||||
query = query.where({ ownerId: userId });
|
query = query.where({ [`${tableName}.ownerId`]: userId });
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = await query.first();
|
const record = await query.first();
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
throw new NotFoundException('Record not found');
|
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;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export class SetupObjectController {
|
|||||||
@TenantId() tenantId: string,
|
@TenantId() tenantId: string,
|
||||||
@Param('objectApiName') objectApiName: 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')
|
@Get(':objectApiName/ui-config')
|
||||||
@@ -58,10 +59,12 @@ export class SetupObjectController {
|
|||||||
@Param('objectApiName') objectApiName: string,
|
@Param('objectApiName') objectApiName: string,
|
||||||
@Body() data: any,
|
@Body() data: any,
|
||||||
) {
|
) {
|
||||||
return this.objectService.createFieldDefinition(
|
const field = await this.objectService.createFieldDefinition(
|
||||||
tenantId,
|
tenantId,
|
||||||
objectApiName,
|
objectApiName,
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
|
// Map the created field to frontend format
|
||||||
|
return this.fieldMapperService.mapFieldToDTO(field);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
v-if="fieldItem.field"
|
v-if="fieldItem.field"
|
||||||
:field="fieldItem.field"
|
:field="fieldItem.field"
|
||||||
:model-value="modelValue?.[fieldItem.field.apiName]"
|
:model-value="modelValue?.[fieldItem.field.apiName]"
|
||||||
|
:record-data="modelValue"
|
||||||
:mode="readonly ? VM.DETAIL : VM.EDIT"
|
:mode="readonly ? VM.DETAIL : VM.EDIT"
|
||||||
@update:model-value="handleFieldUpdate(fieldItem.field.apiName, $event)"
|
@update:model-value="handleFieldUpdate(fieldItem.field.apiName, $event)"
|
||||||
/>
|
/>
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
<FieldRenderer
|
<FieldRenderer
|
||||||
:field="field"
|
:field="field"
|
||||||
:model-value="modelValue?.[field.apiName]"
|
:model-value="modelValue?.[field.apiName]"
|
||||||
|
:record-data="modelValue"
|
||||||
:mode="readonly ? VM.DETAIL : VM.EDIT"
|
:mode="readonly ? VM.DETAIL : VM.EDIT"
|
||||||
@update:model-value="handleFieldUpdate(field.apiName, $event)"
|
@update:model-value="handleFieldUpdate(field.apiName, $event)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -30,10 +30,6 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const { api } = useApi()
|
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({
|
const value = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: (val) => emit('update:modelValue', val),
|
set: (val) => emit('update:modelValue', val),
|
||||||
@@ -49,80 +45,36 @@ const isRelationshipField = computed(() => {
|
|||||||
return [FieldType.BELONGS_TO].includes(props.field.type)
|
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 getRelationPropertyName = () => {
|
||||||
const relationObject = props.field.relationObject || props.field.apiName.replace('Id', '')
|
// Backend attaches related object using field apiName without 'Id' suffix, lowercase
|
||||||
// Convert plural to singular for property name (e.g., 'tenants' -> 'tenant')
|
// e.g., ownerId -> owner, accountId -> account
|
||||||
return relationObject.endsWith('s') ? relationObject.slice(0, -1) : relationObject
|
return props.field.apiName.replace(/Id$/, '').toLowerCase()
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Display value for relationship fields
|
// Display value for relationship fields
|
||||||
const relationshipDisplayValue = computed(() => {
|
const relationshipDisplayValue = computed(() => {
|
||||||
if (!isRelationshipField.value) return props.modelValue || '-'
|
if (!isRelationshipField.value) return props.modelValue || '-'
|
||||||
|
|
||||||
// First, check if the parent record data includes the related object
|
// First, check if the parent record data includes the related object
|
||||||
// This happens when backend uses .withGraphFetched()
|
// This happens when backend uses .withGraphFetched()
|
||||||
if (props.recordData) {
|
if (props.recordData) {
|
||||||
const relationPropertyName = getRelationPropertyName()
|
const relationPropertyName = getRelationPropertyName()
|
||||||
const relatedObject = props.recordData[relationPropertyName]
|
const relatedObject = props.recordData[relationPropertyName]
|
||||||
|
|
||||||
if (relatedObject && typeof relatedObject === 'object') {
|
if (relatedObject && typeof relatedObject === 'object') {
|
||||||
const displayField = props.field.relationDisplayField || 'name'
|
const displayField = props.field.relationDisplayField || 'name'
|
||||||
return relatedObject[displayField] || relatedObject.id || props.modelValue
|
return relatedObject[displayField] || relatedObject.id || props.modelValue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise use the fetched related record
|
// If no related object found in recordData, just show the ID
|
||||||
if (relatedRecord.value) {
|
// (The fetch mechanism is removed to avoid N+1 queries)
|
||||||
const displayField = props.field.relationDisplayField || 'name'
|
|
||||||
return relatedRecord.value[displayField] || relatedRecord.value.id
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show loading state
|
|
||||||
if (loadingRelated.value) {
|
|
||||||
return 'Loading...'
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback to ID
|
|
||||||
return props.modelValue || '-'
|
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 => {
|
const formatValue = (val: any): string => {
|
||||||
if (val === null || val === undefined) return '-'
|
if (val === null || val === undefined) return '-'
|
||||||
|
|
||||||
switch (props.field.type) {
|
switch (props.field.type) {
|
||||||
case FieldType.BELONGS_TO:
|
case FieldType.BELONGS_TO:
|
||||||
return relationshipDisplayValue.value
|
return relationshipDisplayValue.value
|
||||||
@@ -168,6 +120,7 @@ const formatValue = (val: any): string => {
|
|||||||
{{ formatValue(value) }}
|
{{ formatValue(value) }}
|
||||||
</Badge>
|
</Badge>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
||||||
{{ formatValue(value) }}
|
{{ formatValue(value) }}
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ const filteredRecords = computed(() => {
|
|||||||
const fetchRecords = async () => {
|
const fetchRecords = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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 || []
|
records.value = response || []
|
||||||
|
|
||||||
// If we have a modelValue, find the selected record
|
// If we have a modelValue, find the selected record
|
||||||
|
|||||||
@@ -19,10 +19,12 @@ interface Props {
|
|||||||
data: any
|
data: any
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
objectId?: string // For fetching page layout
|
objectId?: string // For fetching page layout
|
||||||
|
baseUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
loading: false,
|
loading: false,
|
||||||
|
baseUrl: '/runtime/objects',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -170,6 +172,7 @@ const usePageLayout = computed(() => {
|
|||||||
:model-value="data[field.apiName]"
|
:model-value="data[field.apiName]"
|
||||||
:record-data="data"
|
:record-data="data"
|
||||||
:mode="ViewMode.DETAIL"
|
:mode="ViewMode.DETAIL"
|
||||||
|
:base-url="baseUrl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -192,6 +195,7 @@ const usePageLayout = computed(() => {
|
|||||||
:model-value="data[field.apiName]"
|
:model-value="data[field.apiName]"
|
||||||
:record-data="data"
|
:record-data="data"
|
||||||
:mode="ViewMode.DETAIL"
|
:mode="ViewMode.DETAIL"
|
||||||
|
:base-url="baseUrl"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ interface Props {
|
|||||||
loading?: boolean
|
loading?: boolean
|
||||||
saving?: boolean
|
saving?: boolean
|
||||||
objectId?: string // For fetching page layout
|
objectId?: string // For fetching page layout
|
||||||
|
baseUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
data: () => ({}),
|
data: () => ({}),
|
||||||
loading: false,
|
loading: false,
|
||||||
saving: false,
|
saving: false,
|
||||||
|
baseUrl: '/runtime/objects',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -260,6 +262,7 @@ const handleFieldUpdate = (fieldName: string, value: any) => {
|
|||||||
:model-value="formData[field.apiName]"
|
:model-value="formData[field.apiName]"
|
||||||
:mode="ViewMode.EDIT"
|
:mode="ViewMode.EDIT"
|
||||||
:error="errors[field.apiName]"
|
:error="errors[field.apiName]"
|
||||||
|
:base-url="baseUrl"
|
||||||
@update:model-value="handleFieldUpdate(field.apiName, $event)"
|
@update:model-value="handleFieldUpdate(field.apiName, $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -283,6 +286,7 @@ const handleFieldUpdate = (fieldName: string, value: any) => {
|
|||||||
:model-value="formData[field.apiName]"
|
:model-value="formData[field.apiName]"
|
||||||
:mode="ViewMode.EDIT"
|
:mode="ViewMode.EDIT"
|
||||||
:error="errors[field.apiName]"
|
:error="errors[field.apiName]"
|
||||||
|
:base-url="baseUrl"
|
||||||
@update:model-value="handleFieldUpdate(field.apiName, $event)"
|
@update:model-value="handleFieldUpdate(field.apiName, $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ interface Props {
|
|||||||
data?: any[]
|
data?: any[]
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
selectable?: boolean
|
selectable?: boolean
|
||||||
|
baseUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
data: () => [],
|
data: () => [],
|
||||||
loading: false,
|
loading: false,
|
||||||
selectable: false,
|
selectable: false,
|
||||||
|
baseUrl: '/runtime/objects',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -207,6 +209,7 @@ const handleAction = (actionId: string) => {
|
|||||||
:model-value="row[field.apiName]"
|
:model-value="row[field.apiName]"
|
||||||
:record-data="row"
|
:record-data="row"
|
||||||
:mode="ViewMode.LIST"
|
:mode="ViewMode.LIST"
|
||||||
|
:base-url="baseUrl"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell @click.stop>
|
<TableCell @click.stop>
|
||||||
|
|||||||
@@ -27,35 +27,35 @@ export const useFields = () => {
|
|||||||
type: fieldDef.type,
|
type: fieldDef.type,
|
||||||
|
|
||||||
// Default values
|
// Default values
|
||||||
placeholder: fieldDef.uiMetadata?.placeholder || fieldDef.description,
|
placeholder: fieldDef.placeholder || fieldDef.description,
|
||||||
helpText: fieldDef.uiMetadata?.helpText || fieldDef.description,
|
helpText: fieldDef.helpText || fieldDef.description,
|
||||||
defaultValue: fieldDef.defaultValue,
|
defaultValue: fieldDef.defaultValue,
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
isRequired: fieldDef.isRequired,
|
isRequired: fieldDef.isRequired,
|
||||||
isReadOnly: isAutoGeneratedField || fieldDef.uiMetadata?.isReadOnly,
|
isReadOnly: isAutoGeneratedField || fieldDef.isReadOnly,
|
||||||
validationRules: fieldDef.uiMetadata?.validationRules || [],
|
validationRules: fieldDef.validationRules || [],
|
||||||
|
|
||||||
// View options - only hide system and auto-generated fields by default
|
// View options - only hide system and auto-generated fields by default
|
||||||
showOnList: fieldDef.uiMetadata?.showOnList ?? true,
|
showOnList: fieldDef.showOnList ?? true,
|
||||||
showOnDetail: fieldDef.uiMetadata?.showOnDetail ?? true,
|
showOnDetail: fieldDef.showOnDetail ?? true,
|
||||||
showOnEdit: fieldDef.uiMetadata?.showOnEdit ?? !shouldHideOnEdit,
|
showOnEdit: fieldDef.showOnEdit ?? !shouldHideOnEdit,
|
||||||
sortable: fieldDef.uiMetadata?.sortable ?? true,
|
sortable: fieldDef.sortable ?? true,
|
||||||
|
|
||||||
// Field type specific
|
// Field type specific
|
||||||
options: fieldDef.uiMetadata?.options,
|
options: fieldDef.options,
|
||||||
rows: fieldDef.uiMetadata?.rows,
|
rows: fieldDef.rows,
|
||||||
min: fieldDef.uiMetadata?.min,
|
min: fieldDef.min,
|
||||||
max: fieldDef.uiMetadata?.max,
|
max: fieldDef.max,
|
||||||
step: fieldDef.uiMetadata?.step,
|
step: fieldDef.step,
|
||||||
accept: fieldDef.uiMetadata?.accept,
|
accept: fieldDef.accept,
|
||||||
relationObject: fieldDef.referenceObject,
|
relationObject: fieldDef.relationObject,
|
||||||
relationDisplayField: fieldDef.uiMetadata?.relationDisplayField,
|
relationDisplayField: fieldDef.relationDisplayField,
|
||||||
|
|
||||||
// Formatting
|
// Formatting
|
||||||
format: fieldDef.uiMetadata?.format,
|
format: fieldDef.format,
|
||||||
prefix: fieldDef.uiMetadata?.prefix,
|
prefix: fieldDef.prefix,
|
||||||
suffix: fieldDef.uiMetadata?.suffix,
|
suffix: fieldDef.suffix,
|
||||||
|
|
||||||
// Advanced
|
// Advanced
|
||||||
dependsOn: fieldDef.uiMetadata?.dependsOn,
|
dependsOn: fieldDef.uiMetadata?.dependsOn,
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ onMounted(async () => {
|
|||||||
:config="listConfig"
|
:config="listConfig"
|
||||||
:data="records"
|
:data="records"
|
||||||
:loading="dataLoading"
|
:loading="dataLoading"
|
||||||
|
:base-url="`/runtime/objects`"
|
||||||
selectable
|
selectable
|
||||||
@row-click="handleRowClick"
|
@row-click="handleRowClick"
|
||||||
@create="handleCreate"
|
@create="handleCreate"
|
||||||
@@ -274,6 +275,7 @@ onMounted(async () => {
|
|||||||
:data="currentRecord"
|
:data="currentRecord"
|
||||||
:loading="dataLoading"
|
:loading="dataLoading"
|
||||||
:object-id="objectDefinition?.id"
|
:object-id="objectDefinition?.id"
|
||||||
|
:base-url="`/runtime/objects`"
|
||||||
@edit="handleEdit"
|
@edit="handleEdit"
|
||||||
@delete="() => handleDelete([currentRecord])"
|
@delete="() => handleDelete([currentRecord])"
|
||||||
@back="handleBack"
|
@back="handleBack"
|
||||||
@@ -287,6 +289,7 @@ onMounted(async () => {
|
|||||||
:loading="dataLoading"
|
:loading="dataLoading"
|
||||||
:saving="saving"
|
:saving="saving"
|
||||||
:object-id="objectDefinition?.id"
|
:object-id="objectDefinition?.id"
|
||||||
|
:base-url="`/runtime/objects`"
|
||||||
@save="handleSaveRecord"
|
@save="handleSaveRecord"
|
||||||
@cancel="handleCancel"
|
@cancel="handleCancel"
|
||||||
@back="handleBack"
|
@back="handleBack"
|
||||||
|
|||||||
Reference in New Issue
Block a user