WIP - add contct and contact details
This commit is contained in:
@@ -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(),
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`;
|
||||||
if (snakeCase.endsWith('y')) {
|
if (snake.endsWith('s')) return snake;
|
||||||
return snakeCase.slice(0, -1) + 'ies';
|
return `${snake}s`;
|
||||||
} else if (snakeCase.endsWith('s')) {
|
};
|
||||||
return snakeCase;
|
|
||||||
} else {
|
const fromApi = toSnakePlural(objectApiName);
|
||||||
return snakeCase + 's';
|
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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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()
|
||||||
|
.replace(/^_/, '');
|
||||||
|
|
||||||
// Simple pluralization
|
if (snake.endsWith('y')) return `${snake.slice(0, -1)}ies`;
|
||||||
if (snakeCase.endsWith('y')) {
|
if (snake.endsWith('s')) return snake;
|
||||||
return snakeCase.slice(0, -1) + 'ies';
|
return `${snake}s`;
|
||||||
} else if (snakeCase.endsWith('s')) {
|
};
|
||||||
return snakeCase + 'es';
|
|
||||||
} else {
|
const fromApi = toSnakePlural(apiName);
|
||||||
return snakeCase + 's';
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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.' }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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] }}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user