WIP - add contct and contact details

This commit is contained in:
Francisco Gaona
2026-01-12 21:08:47 +01:00
parent f8a3cffb64
commit ca11c8cbe7
19 changed files with 551 additions and 94 deletions

View File

@@ -107,18 +107,23 @@ exports.up = async function (knex) {
(await knex('object_definitions').where('apiName', 'ContactDetail').first()) (await knex('object_definitions').where('apiName', 'ContactDetail').first())
.id; .id;
const contactDetailRelationObjects = ['Account', 'Contact']
await knex('field_definitions').insert([ await knex('field_definitions').insert([
{ {
id: knex.raw('(UUID())'), id: knex.raw('(UUID())'),
objectDefinitionId: contactDetailObjectDefId, objectDefinitionId: contactDetailObjectDefId,
apiName: 'relatedObjectType', apiName: 'relatedObjectType',
label: 'Related Object Type', label: 'Related Object Type',
type: 'String', type: 'PICKLIST',
length: 100, length: 100,
isRequired: true, isRequired: true,
isSystem: true, isSystem: false,
isCustom: false, isCustom: false,
displayOrder: 1, displayOrder: 1,
ui_metadata: JSON.stringify({
options: contactDetailRelationObjects.map((value) => ({ label: value, value })),
}),
created_at: knex.fn.now(), created_at: knex.fn.now(),
updated_at: knex.fn.now(), updated_at: knex.fn.now(),
}, },
@@ -127,12 +132,17 @@ exports.up = async function (knex) {
objectDefinitionId: contactDetailObjectDefId, objectDefinitionId: contactDetailObjectDefId,
apiName: 'relatedObjectId', apiName: 'relatedObjectId',
label: 'Related Object ID', label: 'Related Object ID',
type: 'String', type: 'LOOKUP',
length: 36, length: 36,
isRequired: true, isRequired: true,
isSystem: true, isSystem: false,
isCustom: false, isCustom: false,
displayOrder: 2, displayOrder: 2,
ui_metadata: JSON.stringify({
relationObjects: contactDetailRelationObjects,
relationTypeField: 'relatedObjectType',
relationDisplayField: 'name',
}),
created_at: knex.fn.now(), created_at: knex.fn.now(),
updated_at: knex.fn.now(), updated_at: knex.fn.now(),
}, },
@@ -144,7 +154,7 @@ exports.up = async function (knex) {
type: 'String', type: 'String',
length: 50, length: 50,
isRequired: true, isRequired: true,
isSystem: true, isSystem: false,
isCustom: false, isCustom: false,
displayOrder: 3, displayOrder: 3,
created_at: knex.fn.now(), created_at: knex.fn.now(),
@@ -157,7 +167,7 @@ exports.up = async function (knex) {
label: 'Label', label: 'Label',
type: 'String', type: 'String',
length: 100, length: 100,
isSystem: true, isSystem: false,
isCustom: false, isCustom: false,
displayOrder: 4, displayOrder: 4,
created_at: knex.fn.now(), created_at: knex.fn.now(),
@@ -170,7 +180,7 @@ exports.up = async function (knex) {
label: 'Value', label: 'Value',
type: 'Text', type: 'Text',
isRequired: true, isRequired: true,
isSystem: true, isSystem: false,
isCustom: false, isCustom: false,
displayOrder: 5, displayOrder: 5,
created_at: knex.fn.now(), created_at: knex.fn.now(),
@@ -182,7 +192,7 @@ exports.up = async function (knex) {
apiName: 'isPrimary', apiName: 'isPrimary',
label: 'Primary', label: 'Primary',
type: 'Boolean', type: 'Boolean',
isSystem: true, isSystem: false,
isCustom: false, isCustom: false,
displayOrder: 6, displayOrder: 6,
created_at: knex.fn.now(), created_at: knex.fn.now(),

View File

@@ -0,0 +1,101 @@
exports.up = async function (knex) {
const contactDetailObject = await knex('object_definitions')
.where({ apiName: 'ContactDetail' })
.first();
if (!contactDetailObject) return;
const relationObjects = ['Account', 'Contact'];
await knex('field_definitions')
.where({
objectDefinitionId: contactDetailObject.id,
apiName: 'relatedObjectType',
})
.update({
type: 'PICKLIST',
length: 100,
isSystem: false,
ui_metadata: JSON.stringify({
options: relationObjects.map((value) => ({ label: value, value })),
}),
updated_at: knex.fn.now(),
});
await knex('field_definitions')
.where({
objectDefinitionId: contactDetailObject.id,
apiName: 'relatedObjectId',
})
.update({
type: 'LOOKUP',
length: 36,
isSystem: false,
ui_metadata: JSON.stringify({
relationObjects,
relationTypeField: 'relatedObjectType',
relationDisplayField: 'name',
}),
updated_at: knex.fn.now(),
});
await knex('field_definitions')
.whereIn('apiName', [
'detailType',
'label',
'value',
'isPrimary',
])
.andWhere({ objectDefinitionId: contactDetailObject.id })
.update({
isSystem: false,
updated_at: knex.fn.now(),
});
};
exports.down = async function (knex) {
const contactDetailObject = await knex('object_definitions')
.where({ apiName: 'ContactDetail' })
.first();
if (!contactDetailObject) return;
await knex('field_definitions')
.where({
objectDefinitionId: contactDetailObject.id,
apiName: 'relatedObjectType',
})
.update({
type: 'String',
length: 100,
isSystem: true,
ui_metadata: null,
updated_at: knex.fn.now(),
});
await knex('field_definitions')
.where({
objectDefinitionId: contactDetailObject.id,
apiName: 'relatedObjectId',
})
.update({
type: 'String',
length: 36,
isSystem: true,
ui_metadata: null,
updated_at: knex.fn.now(),
});
await knex('field_definitions')
.whereIn('apiName', [
'detailType',
'label',
'value',
'isPrimary',
])
.andWhere({ objectDefinitionId: contactDetailObject.id })
.update({
isSystem: true,
updated_at: knex.fn.now(),
});
};

View File

@@ -0,0 +1,45 @@
exports.up = async function (knex) {
const contactDetailObject = await knex('object_definitions')
.where({ apiName: 'ContactDetail' })
.first();
if (!contactDetailObject) return;
await knex('field_definitions')
.where({ objectDefinitionId: contactDetailObject.id })
.whereIn('apiName', [
'relatedObjectType',
'relatedObjectId',
'detailType',
'label',
'value',
'isPrimary',
])
.update({
isSystem: false,
updated_at: knex.fn.now(),
});
};
exports.down = async function (knex) {
const contactDetailObject = await knex('object_definitions')
.where({ apiName: 'ContactDetail' })
.first();
if (!contactDetailObject) return;
await knex('field_definitions')
.where({ objectDefinitionId: contactDetailObject.id })
.whereIn('apiName', [
'relatedObjectType',
'relatedObjectId',
'detailType',
'label',
'value',
'isPrimary',
])
.update({
isSystem: true,
updated_at: knex.fn.now(),
});
};

View File

@@ -4,10 +4,30 @@ export class ContactDetail extends BaseModel {
static tableName = 'contact_details'; static tableName = 'contact_details';
id!: string; id!: string;
relatedObjectType!: string; relatedObjectType!: 'Account' | 'Contact';
relatedObjectId!: string; relatedObjectId!: string;
detailType!: string; detailType!: string;
label?: string; label?: string;
value!: string; value!: string;
isPrimary!: boolean; isPrimary!: boolean;
// Provide optional relations for each supported parent type.
static relationMappings = {
account: {
relation: BaseModel.BelongsToOneRelation,
modelClass: 'account.model',
join: {
from: 'contact_details.relatedObjectId',
to: 'accounts.id',
},
},
contact: {
relation: BaseModel.BelongsToOneRelation,
modelClass: 'contact.model',
join: {
from: 'contact_details.relatedObjectId',
to: 'contacts.id',
},
},
};
} }

View File

@@ -30,6 +30,8 @@ export interface UIMetadata {
step?: number; // For number step?: number; // For number
accept?: string; // For file/image accept?: string; // For file/image
relationDisplayField?: string; // Which field to display for relations relationDisplayField?: string; // Which field to display for relations
relationObjects?: string[]; // For polymorphic relations
relationTypeField?: string; // Field API name storing the selected relation type
// Formatting // Formatting
format?: string; // Date format, number format, etc. format?: string; // Date format, number format, etc.

View File

@@ -22,7 +22,9 @@ export interface FieldConfigDTO {
step?: number; step?: number;
accept?: string; accept?: string;
relationObject?: string; relationObject?: string;
relationObjects?: string[];
relationDisplayField?: string; relationDisplayField?: string;
relationTypeField?: string;
format?: string; format?: string;
prefix?: string; prefix?: string;
suffix?: string; suffix?: string;
@@ -106,10 +108,12 @@ export class FieldMapperService {
step: uiMetadata.step, step: uiMetadata.step,
accept: uiMetadata.accept, accept: uiMetadata.accept,
relationObject: field.referenceObject, relationObject: field.referenceObject,
relationObjects: uiMetadata.relationObjects,
// For lookup fields, provide default display field if not specified // For lookup fields, provide default display field if not specified
relationDisplayField: isLookupField relationDisplayField: isLookupField
? (uiMetadata.relationDisplayField || 'name') ? (uiMetadata.relationDisplayField || 'name')
: uiMetadata.relationDisplayField, : uiMetadata.relationDisplayField,
relationTypeField: uiMetadata.relationTypeField,
// Formatting // Formatting
format: uiMetadata.format, format: uiMetadata.format,

View File

@@ -16,13 +16,17 @@ export class ModelRegistry {
*/ */
registerModel(apiName: string, modelClass: ModelClass<BaseModel>): void { registerModel(apiName: string, modelClass: ModelClass<BaseModel>): void {
this.registry.set(apiName, modelClass); this.registry.set(apiName, modelClass);
const lowerKey = apiName.toLowerCase();
if (lowerKey !== apiName && !this.registry.has(lowerKey)) {
this.registry.set(lowerKey, modelClass);
}
} }
/** /**
* Get a model from the registry * Get a model from the registry
*/ */
getModel(apiName: string): ModelClass<BaseModel> { getModel(apiName: string): ModelClass<BaseModel> {
const model = this.registry.get(apiName); const model = this.registry.get(apiName) || this.registry.get(apiName.toLowerCase());
if (!model) { if (!model) {
throw new Error(`Model for ${apiName} not found in registry`); throw new Error(`Model for ${apiName} not found in registry`);
} }
@@ -33,7 +37,7 @@ export class ModelRegistry {
* Check if a model exists in the registry * Check if a model exists in the registry
*/ */
hasModel(apiName: string): boolean { hasModel(apiName: string): boolean {
return this.registry.has(apiName); return this.registry.has(apiName) || this.registry.has(apiName.toLowerCase());
} }
/** /**
@@ -46,7 +50,8 @@ export class ModelRegistry {
// Returns undefined if model not found (for models not yet registered) // Returns undefined if model not found (for models not yet registered)
const model = DynamicModelFactory.createModel( const model = DynamicModelFactory.createModel(
metadata, metadata,
(apiName: string) => this.registry.get(apiName), (apiName: string) =>
this.registry.get(apiName) || this.registry.get(apiName.toLowerCase()),
); );
this.registerModel(metadata.apiName, model); this.registerModel(metadata.apiName, model);
return model; return model;

View File

@@ -187,7 +187,7 @@ export class ObjectService {
} }
// Create a migration to create the table // Create a migration to create the table
const tableName = this.getTableName(data.apiName); const tableName = this.getTableName(data.apiName, data.label, data.pluralLabel);
const createTableSQL = this.customMigrationService.generateCreateTableSQL(tableName); const createTableSQL = this.customMigrationService.generateCreateTableSQL(tableName);
try { try {
@@ -273,6 +273,8 @@ export class ObjectService {
referenceObject?: string; referenceObject?: string;
relationObject?: string; relationObject?: string;
relationDisplayField?: string; relationDisplayField?: string;
relationObjects?: string[];
relationTypeField?: string;
defaultValue?: string; defaultValue?: string;
length?: number; length?: number;
precision?: number; precision?: number;
@@ -311,16 +313,13 @@ export class ObjectService {
updated_at: knex.fn.now(), updated_at: knex.fn.now(),
}; };
// Merge UI metadata // Store relationDisplayField in UI metadata if provided
const uiMetadata: any = {}; if (data.relationDisplayField || data.relationObjects || data.relationTypeField) {
if (data.relationDisplayField) { fieldData.ui_metadata = JSON.stringify({
uiMetadata.relationDisplayField = data.relationDisplayField; relationDisplayField: data.relationDisplayField,
} relationObjects: data.relationObjects,
if (data.uiMetadata) { relationTypeField: data.relationTypeField,
Object.assign(uiMetadata, data.uiMetadata); });
}
if (Object.keys(uiMetadata).length > 0) {
fieldData.ui_metadata = JSON.stringify(uiMetadata);
} }
await knex('field_definitions').insert(fieldData); await knex('field_definitions').insert(fieldData);
@@ -329,7 +328,13 @@ export class ObjectService {
// Add the column to the physical table // Add the column to the physical table
const schemaManagementService = new SchemaManagementService(); const schemaManagementService = new SchemaManagementService();
try { try {
await schemaManagementService.addFieldToTable(knex, objectApiName, createdField); await schemaManagementService.addFieldToTable(
knex,
obj.apiName,
createdField,
obj.label,
obj.pluralLabel,
);
this.logger.log(`Added column ${data.apiName} to table for object ${objectApiName}`); this.logger.log(`Added column ${data.apiName} to table for object ${objectApiName}`);
} catch (error) { } catch (error) {
// If column creation fails, delete the field definition to maintain consistency // If column creation fails, delete the field definition to maintain consistency
@@ -342,22 +347,37 @@ export class ObjectService {
} }
// Helper to get table name from object definition // Helper to get table name from object definition
private getTableName(objectApiName: string): string { private getTableName(objectApiName: string, objectLabel?: string, pluralLabel?: string): string {
// Convert CamelCase to snake_case and pluralize const toSnakePlural = (source: string): string => {
// Account -> accounts, ContactPerson -> contact_persons const cleaned = source.replace(/[\s-]+/g, '_');
const snakeCase = objectApiName const snake = cleaned
.replace(/([A-Z])/g, '_$1') .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.toLowerCase() .replace(/__+/g, '_')
.replace(/^_/, ''); .toLowerCase()
.replace(/^_/, '');
// Simple pluralization (can be enhanced)
if (snakeCase.endsWith('y')) { if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`;
return snakeCase.slice(0, -1) + 'ies'; if (snake.endsWith('s')) return snake;
} else if (snakeCase.endsWith('s')) { return `${snake}s`;
return snakeCase; };
} else {
return snakeCase + 's'; const fromApi = toSnakePlural(objectApiName);
const fromLabel = objectLabel ? toSnakePlural(objectLabel) : null;
const fromPlural = pluralLabel ? toSnakePlural(pluralLabel) : null;
// Prefer the label-derived name when it introduces clearer word boundaries
if (fromLabel && fromLabel.includes('_') && !fromApi.includes('_')) {
return fromLabel;
} }
if (fromPlural && fromPlural.includes('_') && !fromApi.includes('_')) {
return fromPlural;
}
// Otherwise fall back to label/plural if they differ from API-derived
if (fromLabel && fromLabel !== fromApi) return fromLabel;
if (fromPlural && fromPlural !== fromApi) return fromPlural;
return fromApi;
} }
/** /**
@@ -417,11 +437,16 @@ export class ObjectService {
private async ensureModelRegistered( private async ensureModelRegistered(
tenantId: string, tenantId: string,
objectApiName: string, objectApiName: string,
objectDefinition?: any,
): Promise<void> { ): Promise<void> {
// Provide a metadata fetcher function that the ModelService can use // Provide a metadata fetcher function that the ModelService can use
const fetchMetadata = async (apiName: string): Promise<ObjectMetadata> => { const fetchMetadata = async (apiName: string): Promise<ObjectMetadata> => {
const objectDef = await this.getObjectDefinition(tenantId, apiName); const objectDef = await this.getObjectDefinition(tenantId, apiName);
const tableName = this.getTableName(apiName); const tableName = this.getTableName(
objectDef.apiName,
objectDef.label,
objectDef.pluralLabel,
);
// Build relations from lookup fields, but only for models that exist // Build relations from lookup fields, but only for models that exist
const lookupFields = objectDef.fields.filter((f: any) => const lookupFields = objectDef.fields.filter((f: any) =>
@@ -472,7 +497,7 @@ export class ObjectService {
try { try {
await this.modelService.ensureModelWithDependencies( await this.modelService.ensureModelWithDependencies(
tenantId, tenantId,
objectApiName, objectDefinition?.apiName || objectApiName,
fetchMetadata, fetchMetadata,
); );
} catch (error) { } catch (error) {
@@ -510,10 +535,14 @@ export class ObjectService {
throw new NotFoundException(`Object ${objectApiName} not found`); throw new NotFoundException(`Object ${objectApiName} not found`);
} }
const tableName = this.getTableName(objectApiName); const tableName = this.getTableName(
objectDefModel.apiName,
objectDefModel.label,
objectDefModel.pluralLabel,
);
// Ensure model is registered // Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName); await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
// Use Objection model // Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
@@ -590,7 +619,7 @@ export class ObjectService {
} }
// Ensure model is registered // Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName); await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
// Use Objection model // Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
@@ -648,19 +677,50 @@ export class ObjectService {
}>> { }>> {
const knex = await this.tenantDbService.getTenantKnexById(tenantId); const knex = await this.tenantDbService.getTenantKnexById(tenantId);
const relatedLookups = await knex('field_definitions as fd') const relatedLookupsRaw = await knex('field_definitions as fd')
.join('object_definitions as od', 'fd.objectDefinitionId', 'od.id') .join('object_definitions as od', 'fd.objectDefinitionId', 'od.id')
.where('fd.type', 'LOOKUP') .where('fd.type', 'LOOKUP')
.andWhere('fd.referenceObject', objectApiName)
.select( .select(
'fd.apiName as fieldApiName', 'fd.apiName as fieldApiName',
'fd.label as fieldLabel', 'fd.label as fieldLabel',
'fd.objectDefinitionId as objectDefinitionId', 'fd.objectDefinitionId as objectDefinitionId',
'fd.referenceObject as referenceObject',
'fd.ui_metadata as uiMetadata',
'od.apiName as childApiName', 'od.apiName as childApiName',
'od.label as childLabel', 'od.label as childLabel',
'od.pluralLabel as childPluralLabel', 'od.pluralLabel as childPluralLabel',
); );
const relatedLookups = relatedLookupsRaw
.map((lookup: any) => {
let uiMetadata: any = {};
if (lookup.uiMetadata) {
try {
uiMetadata = typeof lookup.uiMetadata === 'string'
? JSON.parse(lookup.uiMetadata)
: lookup.uiMetadata;
} catch {
uiMetadata = {};
}
}
return { ...lookup, uiMetadata };
})
.filter((lookup: any) => {
const target = (objectApiName || '').toLowerCase();
const referenceMatch =
typeof lookup.referenceObject === 'string' &&
lookup.referenceObject.toLowerCase() === target;
if (referenceMatch) return true;
const relationObjects = lookup.uiMetadata?.relationObjects;
if (!Array.isArray(relationObjects)) return false;
return relationObjects.some(
(rel: string) => typeof rel === 'string' && rel.toLowerCase() === target,
);
});
if (relatedLookups.length === 0) { if (relatedLookups.length === 0) {
return []; return [];
} }
@@ -689,7 +749,11 @@ export class ObjectService {
); );
return relatedLookups.map((lookup: any) => { return relatedLookups.map((lookup: any) => {
const baseRelationName = this.getTableName(lookup.childApiName); const baseRelationName = this.getTableName(
lookup.childApiName,
lookup.childLabel,
lookup.childPluralLabel,
);
const hasMultiple = lookupCounts[lookup.childApiName] > 1; const hasMultiple = lookupCounts[lookup.childApiName] > 1;
const relationName = hasMultiple const relationName = hasMultiple
? `${baseRelationName}_${lookup.fieldApiName.replace(/Id$/, '').toLowerCase()}` ? `${baseRelationName}_${lookup.fieldApiName.replace(/Id$/, '').toLowerCase()}`
@@ -747,13 +811,14 @@ export class ObjectService {
const editableData = await this.authService.filterEditableFields(data, objectDefModel.fields, user); const editableData = await this.authService.filterEditableFields(data, objectDefModel.fields, user);
// Ensure model is registered // Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName); await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
// Use Objection model // Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
const hasOwnerField = objectDefModel.fields?.some((f: any) => f.apiName === 'ownerId');
const recordData = { const recordData = {
...editableData, ...editableData,
ownerId: userId, // Auto-set owner ...(hasOwnerField ? { ownerId: userId } : {}),
}; };
const record = await boundModel.query().insert(recordData); const record = await boundModel.query().insert(recordData);
return record; return record;
@@ -787,7 +852,11 @@ export class ObjectService {
throw new NotFoundException(`Object ${objectApiName} not found`); throw new NotFoundException(`Object ${objectApiName} not found`);
} }
const tableName = this.getTableName(objectApiName); const tableName = this.getTableName(
objectDefModel.apiName,
objectDefModel.label,
objectDefModel.pluralLabel,
);
// Get existing record // Get existing record
const existingRecord = await knex(tableName).where({ id: recordId }).first(); const existingRecord = await knex(tableName).where({ id: recordId }).first();
@@ -808,7 +877,7 @@ export class ObjectService {
delete editableData.tenantId; delete editableData.tenantId;
// Ensure model is registered // Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName); await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
// Use Objection model // Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
@@ -842,7 +911,11 @@ export class ObjectService {
throw new NotFoundException(`Object ${objectApiName} not found`); throw new NotFoundException(`Object ${objectApiName} not found`);
} }
const tableName = this.getTableName(objectApiName); const tableName = this.getTableName(
objectDefModel.apiName,
objectDefModel.label,
objectDefModel.pluralLabel,
);
// Get existing record // Get existing record
const existingRecord = await knex(tableName).where({ id: recordId }).first(); const existingRecord = await knex(tableName).where({ id: recordId }).first();
@@ -854,7 +927,7 @@ export class ObjectService {
await this.authService.assertCanPerformAction('delete', objectDefModel, existingRecord, user, knex); await this.authService.assertCanPerformAction('delete', objectDefModel, existingRecord, user, knex);
// Ensure model is registered // Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName); await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
// Use Objection model // Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
@@ -905,7 +978,11 @@ export class ObjectService {
} }
// Check if this field has data (count records) // Check if this field has data (count records)
const tableName = this.getTableName(objectApiName); const tableName = this.getTableName(
objectDef.apiName,
objectDef.label,
objectDef.pluralLabel,
);
const recordCount = await knex(tableName).count('* as cnt').first(); const recordCount = await knex(tableName).count('* as cnt').first();
const hasData = recordCount && (recordCount.cnt as number) > 0; const hasData = recordCount && (recordCount.cnt as number) > 0;
@@ -1060,11 +1137,21 @@ export class ObjectService {
} }
// Remove the column from the physical table // Remove the column from the physical table
const tableName = this.getTableName(objectApiName); const tableName = this.getTableName(
objectDef.apiName,
objectDef.label,
objectDef.pluralLabel,
);
const schemaManagementService = new SchemaManagementService(); const schemaManagementService = new SchemaManagementService();
try { try {
await schemaManagementService.removeFieldFromTable(knex, objectApiName, fieldApiName); await schemaManagementService.removeFieldFromTable(
knex,
objectDef.apiName,
fieldApiName,
objectDef.label,
objectDef.pluralLabel,
);
} catch (error) { } catch (error) {
this.logger.warn(`Failed to remove column ${fieldApiName} from table ${tableName}: ${error.message}`); this.logger.warn(`Failed to remove column ${fieldApiName} from table ${tableName}: ${error.message}`);
// Continue with deletion even if column removal fails - field definition must be cleaned up // Continue with deletion even if column removal fails - field definition must be cleaned up

View File

@@ -15,7 +15,11 @@ export class SchemaManagementService {
objectDefinition: ObjectDefinition, objectDefinition: ObjectDefinition,
fields: FieldDefinition[], fields: FieldDefinition[],
) { ) {
const tableName = this.getTableName(objectDefinition.apiName); const tableName = this.getTableName(
objectDefinition.apiName,
objectDefinition.label,
objectDefinition.pluralLabel,
);
// Check if table already exists // Check if table already exists
const exists = await knex.schema.hasTable(tableName); const exists = await knex.schema.hasTable(tableName);
@@ -44,8 +48,10 @@ export class SchemaManagementService {
knex: Knex, knex: Knex,
objectApiName: string, objectApiName: string,
field: FieldDefinition, field: FieldDefinition,
objectLabel?: string,
pluralLabel?: string,
) { ) {
const tableName = this.getTableName(objectApiName); const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
await knex.schema.alterTable(tableName, (table) => { await knex.schema.alterTable(tableName, (table) => {
this.addFieldColumn(table, field); this.addFieldColumn(table, field);
@@ -61,8 +67,10 @@ export class SchemaManagementService {
knex: Knex, knex: Knex,
objectApiName: string, objectApiName: string,
fieldApiName: string, fieldApiName: string,
objectLabel?: string,
pluralLabel?: string,
) { ) {
const tableName = this.getTableName(objectApiName); const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
await knex.schema.alterTable(tableName, (table) => { await knex.schema.alterTable(tableName, (table) => {
table.dropColumn(fieldApiName); table.dropColumn(fieldApiName);
@@ -81,11 +89,13 @@ export class SchemaManagementService {
objectApiName: string, objectApiName: string,
fieldApiName: string, fieldApiName: string,
field: FieldDefinition, field: FieldDefinition,
objectLabel?: string,
pluralLabel?: string,
options?: { options?: {
skipTypeChange?: boolean; // Skip if type change would lose data skipTypeChange?: boolean; // Skip if type change would lose data
}, },
) { ) {
const tableName = this.getTableName(objectApiName); const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
const skipTypeChange = options?.skipTypeChange ?? true; const skipTypeChange = options?.skipTypeChange ?? true;
await knex.schema.alterTable(tableName, (table) => { await knex.schema.alterTable(tableName, (table) => {
@@ -105,8 +115,8 @@ export class SchemaManagementService {
/** /**
* Drop an object table * Drop an object table
*/ */
async dropObjectTable(knex: Knex, objectApiName: string) { async dropObjectTable(knex: Knex, objectApiName: string, objectLabel?: string, pluralLabel?: string) {
const tableName = this.getTableName(objectApiName); const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
await knex.schema.dropTableIfExists(tableName); await knex.schema.dropTableIfExists(tableName);
@@ -241,16 +251,35 @@ export class SchemaManagementService {
/** /**
* Convert object API name to table name (convert to snake_case, pluralize) * Convert object API name to table name (convert to snake_case, pluralize)
*/ */
private getTableName(apiName: string): string { private getTableName(apiName: string, objectLabel?: string, pluralLabel?: string): string {
// Convert PascalCase to snake_case const toSnakePlural = (source: string): string => {
const snakeCase = apiName const cleaned = source.replace(/[\s-]+/g, '_');
.replace(/([A-Z])/g, '_$1') const snake = cleaned
.toLowerCase() .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/^_/, ''); .replace(/__+/g, '_')
.toLowerCase()
.replace(/^_/, '');
// Simple pluralization (append 's' if not already plural) if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`;
// In production, use a proper pluralization library if (snake.endsWith('s')) return snake;
return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`; return `${snake}s`;
};
const fromApi = toSnakePlural(apiName);
const fromLabel = objectLabel ? toSnakePlural(objectLabel) : null;
const fromPlural = pluralLabel ? toSnakePlural(pluralLabel) : null;
if (fromLabel && fromLabel.includes('_') && !fromApi.includes('_')) {
return fromLabel;
}
if (fromPlural && fromPlural.includes('_') && !fromApi.includes('_')) {
return fromPlural;
}
if (fromLabel && fromLabel !== fromApi) return fromLabel;
if (fromPlural && fromPlural !== fromApi) return fromPlural;
return fromApi;
} }
/** /**

View File

@@ -180,8 +180,9 @@ export class AbilityFactory {
} }
} }
// Field permissions exist but this field is not explicitly granted → deny // No explicit rule for this field but other field permissions exist.
return false; // Default to allow so new fields don't get silently stripped and fail validation.
return true;
} }
/** /**

View File

@@ -45,7 +45,11 @@ export class RecordSharingController {
} }
// Get the record to check ownership // Get the record to check ownership
const tableName = this.getTableName(objectDef.apiName); const tableName = this.getTableName(
objectDef.apiName,
objectDef.label,
objectDef.pluralLabel,
);
const record = await knex(tableName) const record = await knex(tableName)
.where({ id: recordId }) .where({ id: recordId })
.first(); .first();
@@ -109,7 +113,11 @@ export class RecordSharingController {
} }
// Get the record to check ownership // Get the record to check ownership
const tableName = this.getTableName(objectDef.apiName); const tableName = this.getTableName(
objectDef.apiName,
objectDef.label,
objectDef.pluralLabel,
);
const record = await knex(tableName) const record = await knex(tableName)
.where({ id: recordId }) .where({ id: recordId })
.first(); .first();
@@ -207,7 +215,11 @@ export class RecordSharingController {
} }
// Get the record to check ownership // Get the record to check ownership
const tableName = this.getTableName(objectDef.apiName); const tableName = this.getTableName(
objectDef.apiName,
objectDef.label,
objectDef.pluralLabel,
);
const record = await knex(tableName) const record = await knex(tableName)
.where({ id: recordId }) .where({ id: recordId })
.first(); .first();
@@ -305,20 +317,34 @@ export class RecordSharingController {
return false; return false;
} }
private getTableName(apiName: string): string { private getTableName(apiName: string, objectLabel?: string, pluralLabel?: string): string {
// Convert CamelCase to snake_case and pluralize const toSnakePlural = (source: string): string => {
const snakeCase = apiName const cleaned = source.replace(/[\s-]+/g, '_');
.replace(/([A-Z])/g, '_$1') const snake = cleaned
.toLowerCase() .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/^_/, ''); .replace(/__+/g, '_')
.toLowerCase()
// Simple pluralization .replace(/^_/, '');
if (snakeCase.endsWith('y')) {
return snakeCase.slice(0, -1) + 'ies'; if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`;
} else if (snakeCase.endsWith('s')) { if (snake.endsWith('s')) return snake;
return snakeCase + 'es'; return `${snake}s`;
} else { };
return snakeCase + 's';
const fromApi = toSnakePlural(apiName);
const fromLabel = objectLabel ? toSnakePlural(objectLabel) : null;
const fromPlural = pluralLabel ? toSnakePlural(pluralLabel) : null;
if (fromLabel && fromLabel.includes('_') && !fromApi.includes('_')) {
return fromLabel;
} }
if (fromPlural && fromPlural.includes('_') && !fromApi.includes('_')) {
return fromPlural;
}
if (fromLabel && fromLabel !== fromApi) return fromLabel;
if (fromPlural && fromPlural !== fromApi) return fromPlural;
return fromApi;
} }
} }

View File

@@ -17,6 +17,7 @@
:record-data="modelValue" :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)"
@update:related-fields="handleRelatedFieldsUpdate"
/> />
</div> </div>
</div> </div>
@@ -34,6 +35,7 @@
:record-data="modelValue" :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)"
@update:related-fields="handleRelatedFieldsUpdate"
/> />
</div> </div>
</div> </div>
@@ -96,6 +98,17 @@ const handleFieldUpdate = (fieldName: string, value: any) => {
emit('update:modelValue', updated) emit('update:modelValue', updated)
} }
const handleRelatedFieldsUpdate = (values: Record<string, any>) => {
if (props.readonly) return
const updated = {
...props.modelValue,
...values,
}
emit('update:modelValue', updated)
}
</script> </script>
<style scoped> <style scoped>

View File

@@ -27,6 +27,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: any] 'update:modelValue': [value: any]
'update:relatedFields': [value: Record<string, any>]
}>() }>()
const { api } = useApi() const { api } = useApi()
@@ -40,6 +41,10 @@ const isReadOnly = computed(() => props.readonly || props.field.isReadOnly || pr
const isEditMode = computed(() => props.mode === ViewMode.EDIT) const isEditMode = computed(() => props.mode === ViewMode.EDIT)
const isListMode = computed(() => props.mode === ViewMode.LIST) const isListMode = computed(() => props.mode === ViewMode.LIST)
const isDetailMode = computed(() => props.mode === ViewMode.DETAIL) const isDetailMode = computed(() => props.mode === ViewMode.DETAIL)
const relationTypeValue = computed(() => {
if (!props.field.relationTypeField) return null
return props.recordData?.[props.field.relationTypeField] ?? null
})
// Check if field is a relationship field // Check if field is a relationship field
const isRelationshipField = computed(() => { const isRelationshipField = computed(() => {
@@ -100,6 +105,13 @@ const formatValue = (val: any): string => {
return String(val) return String(val)
} }
} }
const handleRelationTypeUpdate = (value: string | null) => {
if (!props.field.relationTypeField) return
emit('update:relatedFields', {
[props.field.relationTypeField]: value,
})
}
</script> </script>
<template> <template>
@@ -162,7 +174,9 @@ const formatValue = (val: any): string => {
v-if="field.type === FieldType.BELONGS_TO" v-if="field.type === FieldType.BELONGS_TO"
:field="field" :field="field"
v-model="value" v-model="value"
:relation-type-value="relationTypeValue"
:base-url="baseUrl" :base-url="baseUrl"
@update:relation-type-value="handleRelationTypeUpdate"
/> />
<!-- Text Input --> <!-- Text Input -->

View File

@@ -4,6 +4,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Check, ChevronsUpDown, X } from 'lucide-vue-next' import { Check, ChevronsUpDown, X } from 'lucide-vue-next'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { FieldConfig } from '@/types/field-types' import type { FieldConfig } from '@/types/field-types'
@@ -13,6 +14,7 @@ interface Props {
modelValue: string | null // The ID of the selected record modelValue: string | null // The ID of the selected record
readonly?: boolean readonly?: boolean
baseUrl?: string // Base API URL, defaults to '/central' baseUrl?: string // Base API URL, defaults to '/central'
relationTypeValue?: string | null
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -23,6 +25,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{ const emit = defineEmits<{
'update:modelValue': [value: string | null] 'update:modelValue': [value: string | null]
'update:relationTypeValue': [value: string | null]
}>() }>()
const { api } = useApi() const { api } = useApi()
@@ -31,10 +34,21 @@ const searchQuery = ref('')
const records = ref<any[]>([]) const records = ref<any[]>([])
const loading = ref(false) const loading = ref(false)
const selectedRecord = ref<any | null>(null) const selectedRecord = ref<any | null>(null)
const selectedRelationObject = ref<string | null>(null)
// Get the relation configuration // Get the relation configuration
const relationObject = computed(() => props.field.relationObject || props.field.apiName.replace('Id', '')) const availableRelationObjects = computed(() => {
if (props.field.relationObjects && props.field.relationObjects.length > 0) {
return props.field.relationObjects
}
const fallback = props.field.relationObject || props.field.apiName.replace('Id', '')
return fallback ? [fallback] : []
})
const relationObject = computed(() => selectedRelationObject.value || availableRelationObjects.value[0])
const displayField = computed(() => props.field.relationDisplayField || 'name') const displayField = computed(() => props.field.relationDisplayField || 'name')
const shouldShowTypeSelector = computed(() => availableRelationObjects.value.length > 1)
// Display value for the selected record // Display value for the selected record
const displayValue = computed(() => { const displayValue = computed(() => {
@@ -55,6 +69,11 @@ const filteredRecords = computed(() => {
// Fetch available records for the lookup // Fetch available records for the lookup
const fetchRecords = async () => { const fetchRecords = async () => {
if (!relationObject.value) {
records.value = []
return
}
loading.value = true loading.value = true
try { try {
const endpoint = `${props.baseUrl}/${relationObject.value}/records` const endpoint = `${props.baseUrl}/${relationObject.value}/records`
@@ -72,6 +91,15 @@ const fetchRecords = async () => {
} }
} }
const handleRelationTypeChange = (value: string) => {
selectedRelationObject.value = value
emit('update:relationTypeValue', value)
searchQuery.value = ''
selectedRecord.value = null
emit('update:modelValue', null)
fetchRecords()
}
// Handle record selection // Handle record selection
const selectRecord = (record: any) => { const selectRecord = (record: any) => {
selectedRecord.value = record selectedRecord.value = record
@@ -94,7 +122,24 @@ watch(() => props.modelValue, (newValue) => {
} }
}) })
watch(() => props.relationTypeValue, (newValue) => {
if (!newValue) return
if (availableRelationObjects.value.includes(newValue)) {
selectedRelationObject.value = newValue
fetchRecords()
}
})
onMounted(() => { onMounted(() => {
selectedRelationObject.value = props.relationTypeValue && availableRelationObjects.value.includes(props.relationTypeValue)
? props.relationTypeValue
: availableRelationObjects.value[0] || null
// Emit initial relation type if we have a default selection so hidden relationTypeField gets populated
if (selectedRelationObject.value) {
emit('update:relationTypeValue', selectedRelationObject.value)
}
fetchRecords() fetchRecords()
}) })
</script> </script>
@@ -103,6 +148,25 @@ onMounted(() => {
<div class="lookup-field space-y-2"> <div class="lookup-field space-y-2">
<Popover v-model:open="open"> <Popover v-model:open="open">
<div class="flex gap-2"> <div class="flex gap-2">
<Select
v-if="shouldShowTypeSelector"
:model-value="relationObject"
:disabled="readonly || loading"
@update:model-value="handleRelationTypeChange"
>
<SelectTrigger class="w-40">
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="option in availableRelationObjects"
:key="option"
:value="option"
>
{{ option }}
</SelectItem>
</SelectContent>
</Select>
<PopoverTrigger as-child> <PopoverTrigger as-child>
<Button <Button
variant="outline" variant="outline"
@@ -131,7 +195,7 @@ onMounted(() => {
<Command> <Command>
<CommandInput <CommandInput
v-model="searchQuery" v-model="searchQuery"
placeholder="Search..." :placeholder="relationObject ? `Search ${relationObject}...` : 'Search...'"
/> />
<CommandEmpty> <CommandEmpty>
{{ loading ? 'Loading...' : 'No results found.' }} {{ loading ? 'Loading...' : 'No results found.' }}

View File

@@ -98,10 +98,20 @@ const visibleRelatedLists = computed<RelatedListConfig[]>(() => {
const layoutRelatedLists = pageLayout.value?.relatedLists const layoutRelatedLists = pageLayout.value?.relatedLists
if (!layoutRelatedLists || layoutRelatedLists.length === 0) { if (!layoutRelatedLists || layoutRelatedLists.length === 0) {
return [] // Page layout has no related list selections; show all by default
return relatedLists
} }
return relatedLists.filter(list => layoutRelatedLists.includes(list.relationName)) const normalize = (name: string) => name?.toLowerCase().replace(/[^a-z0-9]/g, '')
const layoutNormalized = layoutRelatedLists.map(normalize)
const filtered = relatedLists.filter(list => {
const name = list.relationName
return layoutRelatedLists.includes(name) || layoutNormalized.includes(normalize(name))
})
// If nothing matched (e.g., relationName changed), fall back to showing all
return filtered.length > 0 ? filtered : relatedLists
}) })
</script> </script>

View File

@@ -159,6 +159,13 @@ const updateFieldValue = (apiName: string, value: any) => {
delete errors.value[apiName] delete errors.value[apiName]
} }
} }
const handleRelatedFieldsUpdate = (values: Record<string, any>) => {
formData.value = {
...formData.value,
...values,
}
}
</script> </script>
<template> <template>
@@ -223,7 +230,9 @@ const updateFieldValue = (apiName: string, value: any) => {
:field="field" :field="field"
:model-value="formData[field.apiName]" :model-value="formData[field.apiName]"
:mode="ViewMode.EDIT" :mode="ViewMode.EDIT"
:record-data="formData"
@update:model-value="updateFieldValue(field.apiName, $event)" @update:model-value="updateFieldValue(field.apiName, $event)"
@update:related-fields="handleRelatedFieldsUpdate"
/> />
<p v-if="errors[field.apiName]" class="text-sm text-destructive"> <p v-if="errors[field.apiName]" class="text-sm text-destructive">
{{ errors[field.apiName] }} {{ errors[field.apiName] }}
@@ -252,7 +261,9 @@ const updateFieldValue = (apiName: string, value: any) => {
:field="field" :field="field"
:model-value="formData[field.apiName]" :model-value="formData[field.apiName]"
:mode="ViewMode.EDIT" :mode="ViewMode.EDIT"
:record-data="formData"
@update:model-value="updateFieldValue(field.apiName, $event)" @update:model-value="updateFieldValue(field.apiName, $event)"
@update:related-fields="handleRelatedFieldsUpdate"
/> />
<p v-if="errors[field.apiName]" class="text-sm text-destructive"> <p v-if="errors[field.apiName]" class="text-sm text-destructive">
{{ errors[field.apiName] }} {{ errors[field.apiName] }}

View File

@@ -176,6 +176,13 @@ const handleFieldUpdate = (fieldName: string, value: any) => {
delete errors.value[fieldName] delete errors.value[fieldName]
} }
} }
const handleRelatedFieldsUpdate = (values: Record<string, any>) => {
formData.value = {
...formData.value,
...values,
}
}
</script> </script>
<template> <template>
@@ -259,10 +266,12 @@ const handleFieldUpdate = (fieldName: string, value: any) => {
<FieldRenderer <FieldRenderer
:field="field" :field="field"
:model-value="formData[field.apiName]" :model-value="formData[field.apiName]"
:record-data="formData"
:mode="ViewMode.EDIT" :mode="ViewMode.EDIT"
:error="errors[field.apiName]" :error="errors[field.apiName]"
:base-url="baseUrl" :base-url="baseUrl"
@update:model-value="handleFieldUpdate(field.apiName, $event)" @update:model-value="handleFieldUpdate(field.apiName, $event)"
@update:related-fields="handleRelatedFieldsUpdate"
/> />
</div> </div>
</div> </div>
@@ -283,10 +292,12 @@ const handleFieldUpdate = (fieldName: string, value: any) => {
<FieldRenderer <FieldRenderer
:field="field" :field="field"
:model-value="formData[field.apiName]" :model-value="formData[field.apiName]"
:record-data="formData"
:mode="ViewMode.EDIT" :mode="ViewMode.EDIT"
:error="errors[field.apiName]" :error="errors[field.apiName]"
:base-url="baseUrl" :base-url="baseUrl"
@update:model-value="handleFieldUpdate(field.apiName, $event)" @update:model-value="handleFieldUpdate(field.apiName, $event)"
@update:related-fields="handleRelatedFieldsUpdate"
/> />
</div> </div>
</div> </div>

View File

@@ -50,7 +50,9 @@ export const useFields = () => {
step: fieldDef.step, step: fieldDef.step,
accept: fieldDef.accept, accept: fieldDef.accept,
relationObject: fieldDef.relationObject, relationObject: fieldDef.relationObject,
relationObjects: fieldDef.relationObjects,
relationDisplayField: fieldDef.relationDisplayField, relationDisplayField: fieldDef.relationDisplayField,
relationTypeField: fieldDef.relationTypeField,
// Formatting // Formatting
format: fieldDef.format, format: fieldDef.format,

View File

@@ -91,7 +91,9 @@ export interface FieldConfig {
step?: number; // For number step?: number; // For number
accept?: string; // For file/image accept?: string; // For file/image
relationObject?: string; // For relationship fields relationObject?: string; // For relationship fields
relationObjects?: string[]; // For polymorphic relationship fields
relationDisplayField?: string; // Which field to display for relations relationDisplayField?: string; // Which field to display for relations
relationTypeField?: string; // Field API name storing the selected relation type
// Formatting // Formatting
format?: string; // Date format, number format, etc. format?: string; // Date format, number format, etc.