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())
.id;
const contactDetailRelationObjects = ['Account', 'Contact']
await knex('field_definitions').insert([
{
id: knex.raw('(UUID())'),
objectDefinitionId: contactDetailObjectDefId,
apiName: 'relatedObjectType',
label: 'Related Object Type',
type: 'String',
type: 'PICKLIST',
length: 100,
isRequired: true,
isSystem: true,
isSystem: false,
isCustom: false,
displayOrder: 1,
ui_metadata: JSON.stringify({
options: contactDetailRelationObjects.map((value) => ({ label: value, value })),
}),
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
@@ -127,12 +132,17 @@ exports.up = async function (knex) {
objectDefinitionId: contactDetailObjectDefId,
apiName: 'relatedObjectId',
label: 'Related Object ID',
type: 'String',
type: 'LOOKUP',
length: 36,
isRequired: true,
isSystem: true,
isSystem: false,
isCustom: false,
displayOrder: 2,
ui_metadata: JSON.stringify({
relationObjects: contactDetailRelationObjects,
relationTypeField: 'relatedObjectType',
relationDisplayField: 'name',
}),
created_at: knex.fn.now(),
updated_at: knex.fn.now(),
},
@@ -144,7 +154,7 @@ exports.up = async function (knex) {
type: 'String',
length: 50,
isRequired: true,
isSystem: true,
isSystem: false,
isCustom: false,
displayOrder: 3,
created_at: knex.fn.now(),
@@ -157,7 +167,7 @@ exports.up = async function (knex) {
label: 'Label',
type: 'String',
length: 100,
isSystem: true,
isSystem: false,
isCustom: false,
displayOrder: 4,
created_at: knex.fn.now(),
@@ -170,7 +180,7 @@ exports.up = async function (knex) {
label: 'Value',
type: 'Text',
isRequired: true,
isSystem: true,
isSystem: false,
isCustom: false,
displayOrder: 5,
created_at: knex.fn.now(),
@@ -182,7 +192,7 @@ exports.up = async function (knex) {
apiName: 'isPrimary',
label: 'Primary',
type: 'Boolean',
isSystem: true,
isSystem: false,
isCustom: false,
displayOrder: 6,
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';
id!: string;
relatedObjectType!: string;
relatedObjectType!: 'Account' | 'Contact';
relatedObjectId!: string;
detailType!: string;
label?: string;
value!: string;
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
accept?: string; // For file/image
relationDisplayField?: string; // Which field to display for relations
relationObjects?: string[]; // For polymorphic relations
relationTypeField?: string; // Field API name storing the selected relation type
// Formatting
format?: string; // Date format, number format, etc.

View File

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

View File

@@ -16,13 +16,17 @@ export class ModelRegistry {
*/
registerModel(apiName: string, modelClass: ModelClass<BaseModel>): void {
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
*/
getModel(apiName: string): ModelClass<BaseModel> {
const model = this.registry.get(apiName);
const model = this.registry.get(apiName) || this.registry.get(apiName.toLowerCase());
if (!model) {
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
*/
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)
const model = DynamicModelFactory.createModel(
metadata,
(apiName: string) => this.registry.get(apiName),
(apiName: string) =>
this.registry.get(apiName) || this.registry.get(apiName.toLowerCase()),
);
this.registerModel(metadata.apiName, model);
return model;

View File

@@ -187,7 +187,7 @@ export class ObjectService {
}
// 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);
try {
@@ -273,6 +273,8 @@ export class ObjectService {
referenceObject?: string;
relationObject?: string;
relationDisplayField?: string;
relationObjects?: string[];
relationTypeField?: string;
defaultValue?: string;
length?: number;
precision?: number;
@@ -311,16 +313,13 @@ export class ObjectService {
updated_at: knex.fn.now(),
};
// Merge UI metadata
const uiMetadata: any = {};
if (data.relationDisplayField) {
uiMetadata.relationDisplayField = data.relationDisplayField;
}
if (data.uiMetadata) {
Object.assign(uiMetadata, data.uiMetadata);
}
if (Object.keys(uiMetadata).length > 0) {
fieldData.ui_metadata = JSON.stringify(uiMetadata);
// Store relationDisplayField in UI metadata if provided
if (data.relationDisplayField || data.relationObjects || data.relationTypeField) {
fieldData.ui_metadata = JSON.stringify({
relationDisplayField: data.relationDisplayField,
relationObjects: data.relationObjects,
relationTypeField: data.relationTypeField,
});
}
await knex('field_definitions').insert(fieldData);
@@ -329,7 +328,13 @@ export class ObjectService {
// Add the column to the physical table
const schemaManagementService = new SchemaManagementService();
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}`);
} catch (error) {
// 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
private getTableName(objectApiName: string): string {
// Convert CamelCase to snake_case and pluralize
// Account -> accounts, ContactPerson -> contact_persons
const snakeCase = objectApiName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
// Simple pluralization (can be enhanced)
if (snakeCase.endsWith('y')) {
return snakeCase.slice(0, -1) + 'ies';
} else if (snakeCase.endsWith('s')) {
return snakeCase;
} else {
return snakeCase + 's';
private getTableName(objectApiName: string, objectLabel?: string, pluralLabel?: string): string {
const toSnakePlural = (source: string): string => {
const cleaned = source.replace(/[\s-]+/g, '_');
const snake = cleaned
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/__+/g, '_')
.toLowerCase()
.replace(/^_/, '');
if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`;
if (snake.endsWith('s')) return snake;
return `${snake}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(
tenantId: string,
objectApiName: string,
objectDefinition?: any,
): 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);
const tableName = this.getTableName(
objectDef.apiName,
objectDef.label,
objectDef.pluralLabel,
);
// Build relations from lookup fields, but only for models that exist
const lookupFields = objectDef.fields.filter((f: any) =>
@@ -472,7 +497,7 @@ export class ObjectService {
try {
await this.modelService.ensureModelWithDependencies(
tenantId,
objectApiName,
objectDefinition?.apiName || objectApiName,
fetchMetadata,
);
} catch (error) {
@@ -510,10 +535,14 @@ export class ObjectService {
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
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
// Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
@@ -590,7 +619,7 @@ export class ObjectService {
}
// Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
// Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
@@ -648,19 +677,50 @@ export class ObjectService {
}>> {
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')
.where('fd.type', 'LOOKUP')
.andWhere('fd.referenceObject', objectApiName)
.select(
'fd.apiName as fieldApiName',
'fd.label as fieldLabel',
'fd.objectDefinitionId as objectDefinitionId',
'fd.referenceObject as referenceObject',
'fd.ui_metadata as uiMetadata',
'od.apiName as childApiName',
'od.label as childLabel',
'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) {
return [];
}
@@ -689,7 +749,11 @@ export class ObjectService {
);
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 relationName = hasMultiple
? `${baseRelationName}_${lookup.fieldApiName.replace(/Id$/, '').toLowerCase()}`
@@ -747,13 +811,14 @@ export class ObjectService {
const editableData = await this.authService.filterEditableFields(data, objectDefModel.fields, user);
// Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
// Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
const hasOwnerField = objectDefModel.fields?.some((f: any) => f.apiName === 'ownerId');
const recordData = {
...editableData,
ownerId: userId, // Auto-set owner
...(hasOwnerField ? { ownerId: userId } : {}),
};
const record = await boundModel.query().insert(recordData);
return record;
@@ -787,7 +852,11 @@ export class ObjectService {
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
const existingRecord = await knex(tableName).where({ id: recordId }).first();
@@ -808,7 +877,7 @@ export class ObjectService {
delete editableData.tenantId;
// Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
// Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
@@ -842,7 +911,11 @@ export class ObjectService {
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
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);
// Ensure model is registered
await this.ensureModelRegistered(resolvedTenantId, objectApiName);
await this.ensureModelRegistered(resolvedTenantId, objectApiName, objectDefModel);
// Use Objection model
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
@@ -905,7 +978,11 @@ export class ObjectService {
}
// 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 hasData = recordCount && (recordCount.cnt as number) > 0;
@@ -1060,11 +1137,21 @@ export class ObjectService {
}
// 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();
try {
await schemaManagementService.removeFieldFromTable(knex, objectApiName, fieldApiName);
await schemaManagementService.removeFieldFromTable(
knex,
objectDef.apiName,
fieldApiName,
objectDef.label,
objectDef.pluralLabel,
);
} catch (error) {
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

View File

@@ -15,7 +15,11 @@ export class SchemaManagementService {
objectDefinition: ObjectDefinition,
fields: FieldDefinition[],
) {
const tableName = this.getTableName(objectDefinition.apiName);
const tableName = this.getTableName(
objectDefinition.apiName,
objectDefinition.label,
objectDefinition.pluralLabel,
);
// Check if table already exists
const exists = await knex.schema.hasTable(tableName);
@@ -44,8 +48,10 @@ export class SchemaManagementService {
knex: Knex,
objectApiName: string,
field: FieldDefinition,
objectLabel?: string,
pluralLabel?: string,
) {
const tableName = this.getTableName(objectApiName);
const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
await knex.schema.alterTable(tableName, (table) => {
this.addFieldColumn(table, field);
@@ -61,8 +67,10 @@ export class SchemaManagementService {
knex: Knex,
objectApiName: 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) => {
table.dropColumn(fieldApiName);
@@ -81,11 +89,13 @@ export class SchemaManagementService {
objectApiName: string,
fieldApiName: string,
field: FieldDefinition,
objectLabel?: string,
pluralLabel?: string,
options?: {
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;
await knex.schema.alterTable(tableName, (table) => {
@@ -105,8 +115,8 @@ export class SchemaManagementService {
/**
* Drop an object table
*/
async dropObjectTable(knex: Knex, objectApiName: string) {
const tableName = this.getTableName(objectApiName);
async dropObjectTable(knex: Knex, objectApiName: string, objectLabel?: string, pluralLabel?: string) {
const tableName = this.getTableName(objectApiName, objectLabel, pluralLabel);
await knex.schema.dropTableIfExists(tableName);
@@ -241,16 +251,35 @@ export class SchemaManagementService {
/**
* Convert object API name to table name (convert to snake_case, pluralize)
*/
private getTableName(apiName: string): string {
// Convert PascalCase to snake_case
const snakeCase = apiName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
private getTableName(apiName: string, objectLabel?: string, pluralLabel?: string): string {
const toSnakePlural = (source: string): string => {
const cleaned = source.replace(/[\s-]+/g, '_');
const snake = cleaned
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/__+/g, '_')
.toLowerCase()
.replace(/^_/, '');
// Simple pluralization (append 's' if not already plural)
// In production, use a proper pluralization library
return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`;
if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`;
if (snake.endsWith('s')) return snake;
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
return false;
// No explicit rule for this field but other field permissions exist.
// 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
const tableName = this.getTableName(objectDef.apiName);
const tableName = this.getTableName(
objectDef.apiName,
objectDef.label,
objectDef.pluralLabel,
);
const record = await knex(tableName)
.where({ id: recordId })
.first();
@@ -109,7 +113,11 @@ export class RecordSharingController {
}
// 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)
.where({ id: recordId })
.first();
@@ -207,7 +215,11 @@ export class RecordSharingController {
}
// 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)
.where({ id: recordId })
.first();
@@ -305,20 +317,34 @@ export class RecordSharingController {
return false;
}
private getTableName(apiName: string): string {
// Convert CamelCase to snake_case and pluralize
const snakeCase = apiName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
// Simple pluralization
if (snakeCase.endsWith('y')) {
return snakeCase.slice(0, -1) + 'ies';
} else if (snakeCase.endsWith('s')) {
return snakeCase + 'es';
} else {
return snakeCase + 's';
private getTableName(apiName: string, objectLabel?: string, pluralLabel?: string): string {
const toSnakePlural = (source: string): string => {
const cleaned = source.replace(/[\s-]+/g, '_');
const snake = cleaned
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.replace(/__+/g, '_')
.toLowerCase()
.replace(/^_/, '');
if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`;
if (snake.endsWith('s')) return snake;
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

@@ -17,6 +17,7 @@
:record-data="modelValue"
:mode="readonly ? VM.DETAIL : VM.EDIT"
@update:model-value="handleFieldUpdate(fieldItem.field.apiName, $event)"
@update:related-fields="handleRelatedFieldsUpdate"
/>
</div>
</div>
@@ -34,6 +35,7 @@
:record-data="modelValue"
:mode="readonly ? VM.DETAIL : VM.EDIT"
@update:model-value="handleFieldUpdate(field.apiName, $event)"
@update:related-fields="handleRelatedFieldsUpdate"
/>
</div>
</div>
@@ -96,6 +98,17 @@ const handleFieldUpdate = (fieldName: string, value: any) => {
emit('update:modelValue', updated)
}
const handleRelatedFieldsUpdate = (values: Record<string, any>) => {
if (props.readonly) return
const updated = {
...props.modelValue,
...values,
}
emit('update:modelValue', updated)
}
</script>
<style scoped>

View File

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

View File

@@ -4,6 +4,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
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 { cn } from '@/lib/utils'
import type { FieldConfig } from '@/types/field-types'
@@ -13,6 +14,7 @@ interface Props {
modelValue: string | null // The ID of the selected record
readonly?: boolean
baseUrl?: string // Base API URL, defaults to '/central'
relationTypeValue?: string | null
}
const props = withDefaults(defineProps<Props>(), {
@@ -23,6 +25,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<{
'update:modelValue': [value: string | null]
'update:relationTypeValue': [value: string | null]
}>()
const { api } = useApi()
@@ -31,10 +34,21 @@ const searchQuery = ref('')
const records = ref<any[]>([])
const loading = ref(false)
const selectedRecord = ref<any | null>(null)
const selectedRelationObject = ref<string | null>(null)
// 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 shouldShowTypeSelector = computed(() => availableRelationObjects.value.length > 1)
// Display value for the selected record
const displayValue = computed(() => {
@@ -55,6 +69,11 @@ const filteredRecords = computed(() => {
// Fetch available records for the lookup
const fetchRecords = async () => {
if (!relationObject.value) {
records.value = []
return
}
loading.value = true
try {
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
const selectRecord = (record: any) => {
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(() => {
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()
})
</script>
@@ -103,6 +148,25 @@ onMounted(() => {
<div class="lookup-field space-y-2">
<Popover v-model:open="open">
<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>
<Button
variant="outline"
@@ -131,7 +195,7 @@ onMounted(() => {
<Command>
<CommandInput
v-model="searchQuery"
placeholder="Search..."
:placeholder="relationObject ? `Search ${relationObject}...` : 'Search...'"
/>
<CommandEmpty>
{{ loading ? 'Loading...' : 'No results found.' }}

View File

@@ -98,10 +98,20 @@ const visibleRelatedLists = computed<RelatedListConfig[]>(() => {
const layoutRelatedLists = pageLayout.value?.relatedLists
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>

View File

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

View File

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

View File

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

View File

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