diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e1f30d8..cecfd56 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -125,6 +125,7 @@ model FieldDefinition { isSystem Boolean @default(false) isCustom Boolean @default(true) displayOrder Int @default(0) + uiMetadata Json? @map("ui_metadata") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/backend/src/object/field-mapper.service.ts b/backend/src/object/field-mapper.service.ts index dcf31f1..5043ee8 100644 --- a/backend/src/object/field-mapper.service.ts +++ b/backend/src/object/field-mapper.service.ts @@ -51,13 +51,29 @@ export class FieldMapperService { * Convert a field definition from the database to a frontend-friendly FieldConfig */ mapFieldToDTO(field: any): FieldConfigDTO { - const uiMetadata = field.uiMetadata || {}; + // Parse ui_metadata if it's a JSON string or object + let uiMetadata: any = {}; + const metadataField = field.ui_metadata || field.uiMetadata; + if (metadataField) { + if (typeof metadataField === 'string') { + try { + uiMetadata = JSON.parse(metadataField); + } catch (e) { + uiMetadata = {}; + } + } else { + uiMetadata = metadataField; + } + } + + const frontendType = this.mapFieldType(field.type); + const isLookupField = frontendType === 'belongsTo' || field.type.toLowerCase().includes('lookup'); return { id: field.id, apiName: field.apiName, label: field.label, - type: this.mapFieldType(field.type), + type: frontendType, // Display properties placeholder: uiMetadata.placeholder || field.description, @@ -82,7 +98,10 @@ export class FieldMapperService { step: uiMetadata.step, accept: uiMetadata.accept, relationObject: field.referenceObject, - relationDisplayField: uiMetadata.relationDisplayField, + // For lookup fields, provide default display field if not specified + relationDisplayField: isLookupField + ? (uiMetadata.relationDisplayField || 'name') + : uiMetadata.relationDisplayField, // Formatting format: uiMetadata.format, diff --git a/backend/src/object/models/dynamic-model.factory.ts b/backend/src/object/models/dynamic-model.factory.ts index e0b3295..669de82 100644 --- a/backend/src/object/models/dynamic-model.factory.ts +++ b/backend/src/object/models/dynamic-model.factory.ts @@ -30,8 +30,13 @@ export interface ObjectMetadata { export class DynamicModelFactory { /** * Create a dynamic model class from object metadata + * @param meta Object metadata + * @param getModel Function to retrieve model classes from registry */ - static createModel(meta: ObjectMetadata): ModelClass { + static createModel( + meta: ObjectMetadata, + getModel?: (apiName: string) => ModelClass, + ): ModelClass { const { tableName, fields, apiName, relations = [] } = meta; // Build JSON schema properties @@ -57,12 +62,16 @@ export class DynamicModelFactory { } } - // Build relation mappings - const relationMappings: RelationMappings = {}; - for (const rel of relations) { - // Relations are resolved dynamically, skipping for now - // Will be handled by ModelRegistry.getModel() - } + // Build relation mappings from lookup fields + const lookupFields = fields.filter(f => f.type === 'LOOKUP' && f.referenceObject); + + // Store lookup fields metadata for later use + const lookupFieldsInfo = lookupFields.map(f => ({ + apiName: f.apiName, + relationName: f.apiName.replace(/Id$/, '').toLowerCase(), + referenceObject: f.referenceObject, + targetTable: this.getTableName(f.referenceObject), + })); // Create the dynamic model class extending Model directly class DynamicModel extends Model { @@ -76,8 +85,41 @@ export class DynamicModelFactory { static tableName = tableName; static objectApiName = apiName; + + static lookupFields = lookupFieldsInfo; - static relationMappings = relationMappings; + static get relationMappings(): RelationMappings { + const mappings: RelationMappings = {}; + + // Build relation mappings from lookup fields + for (const lookupInfo of lookupFieldsInfo) { + // Use getModel function if provided, otherwise use string reference + let modelClass: any = lookupInfo.referenceObject; + + if (getModel) { + const resolvedModel = getModel(lookupInfo.referenceObject); + // Only use resolved model if it exists, otherwise skip this relation + // It will be resolved later when the model is registered + if (resolvedModel) { + modelClass = resolvedModel; + } else { + // Skip this relation if model not found yet + continue; + } + } + + mappings[lookupInfo.relationName] = { + relation: Model.BelongsToOneRelation, + modelClass, + join: { + from: `${tableName}.${lookupInfo.apiName}`, + to: `${lookupInfo.targetTable}.id`, + }, + }; + } + + return mappings; + } static get jsonSchema() { return { @@ -159,4 +201,16 @@ export class DynamicModelFactory { return { type: 'string' }; } } + + /** + * Get table name from object API name + */ + private static getTableName(objectApiName: string): string { + // Convert PascalCase/camelCase to snake_case and pluralize + const snakeCase = objectApiName + .replace(/([A-Z])/g, '_$1') + .toLowerCase() + .replace(/^_/, ''); + return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`; + } } diff --git a/backend/src/object/models/model.registry.ts b/backend/src/object/models/model.registry.ts index 0c8fe47..cd728ef 100644 --- a/backend/src/object/models/model.registry.ts +++ b/backend/src/object/models/model.registry.ts @@ -42,7 +42,12 @@ export class ModelRegistry { createAndRegisterModel( metadata: ObjectMetadata, ): ModelClass { - const model = DynamicModelFactory.createModel(metadata); + // Create model with a getModel function that resolves from this registry + // Returns undefined if model not found (for models not yet registered) + const model = DynamicModelFactory.createModel( + metadata, + (apiName: string) => this.registry.get(apiName), + ); this.registerModel(metadata.apiName, model); return model; } diff --git a/backend/src/object/object.service.ts b/backend/src/object/object.service.ts index 2dd48df..009aa15 100644 --- a/backend/src/object/object.service.ts +++ b/backend/src/object/object.service.ts @@ -1,4 +1,4 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException, Logger } from '@nestjs/common'; import { TenantDatabaseService } from '../tenant/tenant-database.service'; import { CustomMigrationService } from '../migration/custom-migration.service'; import { ModelService } from './models/model.service'; @@ -6,6 +6,8 @@ import { ObjectMetadata } from './models/dynamic-model.factory'; @Injectable() export class ObjectService { + private readonly logger = new Logger(ObjectService.name); + constructor( private tenantDbService: TenantDatabaseService, private customMigrationService: CustomMigrationService, @@ -107,14 +109,14 @@ export class ObjectService { description: 'The user who owns this record', isRequired: false, // Auto-set by system isUnique: false, - referenceObject: null, + referenceObject: 'User', isSystem: true, isCustom: false, }, { apiName: 'name', label: 'Name', - type: 'TEXT', + type: 'STRING', description: 'The primary name field for this record', isRequired: false, // Optional field isUnique: false, @@ -156,13 +158,22 @@ export class ObjectService { .first(); if (!existingField) { - await knex('field_definitions').insert({ + const fieldData: any = { id: knex.raw('(UUID())'), objectDefinitionId: objectDef.id, ...field, created_at: knex.fn.now(), updated_at: knex.fn.now(), - }); + }; + + // For lookup fields, set ui_metadata with relationDisplayField + if (field.type === 'LOOKUP') { + fieldData.ui_metadata = JSON.stringify({ + relationDisplayField: 'name', + }); + } + + await knex('field_definitions').insert(fieldData); } } @@ -226,6 +237,8 @@ export class ObjectService { isRequired?: boolean; isUnique?: boolean; referenceObject?: string; + relationObject?: string; + relationDisplayField?: string; defaultValue?: string; }, ) { @@ -233,13 +246,35 @@ export class ObjectService { const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); const obj = await this.getObjectDefinition(tenantId, objectApiName); - const [id] = await knex('field_definitions').insert({ + // Convert frontend type to database type + const dbFieldType = this.convertFrontendFieldType(data.type); + + // Use relationObject if provided (alias for referenceObject) + const referenceObject = data.referenceObject || data.relationObject; + + const fieldData: any = { id: knex.raw('(UUID())'), objectDefinitionId: obj.id, - ...data, + apiName: data.apiName, + label: data.label, + type: dbFieldType, + description: data.description, + isRequired: data.isRequired ?? false, + isUnique: data.isUnique ?? false, + referenceObject: referenceObject, + defaultValue: data.defaultValue, created_at: knex.fn.now(), updated_at: knex.fn.now(), - }); + }; + + // Store relationDisplayField in UI metadata if provided + if (data.relationDisplayField) { + fieldData.ui_metadata = JSON.stringify({ + relationDisplayField: data.relationDisplayField, + }); + } + + const [id] = await knex('field_definitions').insert(fieldData); return knex('field_definitions').where({ id }).first(); } @@ -279,6 +314,39 @@ export class ObjectService { }; } + /** + * Convert frontend field type to database field type + */ + private convertFrontendFieldType(frontendType: string): string { + const typeMap: Record = { + 'text': 'TEXT', + 'textarea': 'LONG_TEXT', + 'password': 'TEXT', + 'email': 'EMAIL', + 'number': 'NUMBER', + 'currency': 'CURRENCY', + 'percent': 'PERCENT', + 'select': 'PICKLIST', + 'multiSelect': 'MULTI_PICKLIST', + 'boolean': 'BOOLEAN', + 'date': 'DATE', + 'datetime': 'DATE_TIME', + 'time': 'TIME', + 'url': 'URL', + 'color': 'TEXT', + 'json': 'JSON', + 'belongsTo': 'LOOKUP', + 'hasMany': 'LOOKUP', + 'manyToMany': 'LOOKUP', + 'markdown': 'LONG_TEXT', + 'code': 'LONG_TEXT', + 'file': 'FILE', + 'image': 'IMAGE', + }; + + return typeMap[frontendType] || 'TEXT'; + } + // Runtime endpoints - CRUD operations async getRecords( tenantId: string, @@ -289,8 +357,8 @@ export class ObjectService { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); - // Verify object exists - await this.getObjectDefinition(tenantId, objectApiName); + // Verify object exists and get field definitions + const objectDef = await this.getObjectDefinition(tenantId, objectApiName); const tableName = this.getTableName(objectApiName); @@ -301,6 +369,23 @@ export class ObjectService { const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); let query = boundModel.query(); + // Build graph expression for lookup fields + const lookupFields = objectDef.fields?.filter(f => + f.type === 'LOOKUP' && f.referenceObject + ) || []; + + if (lookupFields.length > 0) { + // Build relation expression - use singular lowercase for relation name + const relationExpression = lookupFields + .map(f => f.apiName.replace(/Id$/, '').toLowerCase()) + .filter(Boolean) + .join(', '); + + if (relationExpression) { + query = query.withGraphFetched(`[${relationExpression}]`); + } + } + // Add ownership filter if ownerId field exists const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); if (hasOwner) { @@ -315,15 +400,16 @@ export class ObjectService { return query.select('*'); } } catch (error) { - console.warn(`Could not use Objection model for ${objectApiName}:`, error.message); + this.logger.warn(`Could not use Objection model for ${objectApiName}, falling back to manual join: ${error.message}`); } - // Fallback to raw Knex + // Fallback to manual data hydration let query = knex(tableName); + // Add ownership filter if ownerId field exists const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); if (hasOwner) { - query = query.where({ ownerId: userId }); + query = query.where({ [`${tableName}.ownerId`]: userId }); } // Apply additional filters @@ -331,7 +417,49 @@ export class ObjectService { query = query.where(filters); } - return query.select('*'); + // Get base records + const records = await query.select(`${tableName}.*`); + + // Fetch and attach related records for lookup fields + const lookupFields = objectDef.fields?.filter(f => + f.type === 'LOOKUP' && f.referenceObject + ) || []; + + if (lookupFields.length > 0 && records.length > 0) { + for (const field of lookupFields) { + const relationName = field.apiName.replace(/Id$/, '').toLowerCase(); + const relatedTable = this.getTableName(field.referenceObject); + + // Get unique IDs to fetch + const relatedIds = [...new Set( + records + .map(r => r[field.apiName]) + .filter(Boolean) + )]; + + if (relatedIds.length > 0) { + // Fetch all related records in one query + const relatedRecords = await knex(relatedTable) + .whereIn('id', relatedIds) + .select('*'); + + // Create a map for quick lookup + const relatedMap = new Map( + relatedRecords.map(r => [r.id, r]) + ); + + // Attach related records to main records + for (const record of records) { + const relatedId = record[field.apiName]; + if (relatedId && relatedMap.has(relatedId)) { + record[relationName] = relatedMap.get(relatedId); + } + } + } + } + } + + return records; } async getRecord( @@ -343,8 +471,8 @@ export class ObjectService { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); - // Verify object exists - await this.getObjectDefinition(tenantId, objectApiName); + // Verify object exists and get field definitions + const objectDef = await this.getObjectDefinition(tenantId, objectApiName); const tableName = this.getTableName(objectApiName); @@ -355,6 +483,23 @@ export class ObjectService { const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); let query = boundModel.query().where({ id: recordId }); + // Build graph expression for lookup fields + const lookupFields = objectDef.fields?.filter(f => + f.type === 'LOOKUP' && f.referenceObject + ) || []; + + if (lookupFields.length > 0) { + // Build relation expression - use singular lowercase for relation name + const relationExpression = lookupFields + .map(f => f.apiName.replace(/Id$/, '').toLowerCase()) + .filter(Boolean) + .join(', '); + + if (relationExpression) { + query = query.withGraphFetched(`[${relationExpression}]`); + } + } + // Add ownership filter if ownerId field exists const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); if (hasOwner) { @@ -368,23 +513,48 @@ export class ObjectService { return record; } } catch (error) { - console.warn(`Could not use Objection model for ${objectApiName}:`, error.message); + this.logger.warn(`Could not use Objection model for ${objectApiName}, falling back to manual join: ${error.message}`); } - // Fallback to raw Knex - let query = knex(tableName).where({ id: recordId }); + // Fallback to manual data hydration + let query = knex(tableName).where({ [`${tableName}.id`]: recordId }); + // Add ownership filter if ownerId field exists const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); if (hasOwner) { - query = query.where({ ownerId: userId }); + query = query.where({ [`${tableName}.ownerId`]: userId }); } const record = await query.first(); - + if (!record) { throw new NotFoundException('Record not found'); } - + + // Fetch and attach related records for lookup fields + const lookupFields = objectDef.fields?.filter(f => + f.type === 'LOOKUP' && f.referenceObject + ) || []; + + if (lookupFields.length > 0) { + for (const field of lookupFields) { + const relationName = field.apiName.replace(/Id$/, '').toLowerCase(); + const relatedTable = this.getTableName(field.referenceObject); + const relatedId = record[field.apiName]; + + if (relatedId) { + // Fetch the related record + const relatedRecord = await knex(relatedTable) + .where({ id: relatedId }) + .first(); + + if (relatedRecord) { + record[relationName] = relatedRecord; + } + } + } + } + return record; } diff --git a/backend/src/object/setup-object.controller.ts b/backend/src/object/setup-object.controller.ts index 511a82c..af849fa 100644 --- a/backend/src/object/setup-object.controller.ts +++ b/backend/src/object/setup-object.controller.ts @@ -29,7 +29,8 @@ export class SetupObjectController { @TenantId() tenantId: string, @Param('objectApiName') objectApiName: string, ) { - return this.objectService.getObjectDefinition(tenantId, objectApiName); + const objectDef = await this.objectService.getObjectDefinition(tenantId, objectApiName); + return this.fieldMapperService.mapObjectDefinitionToDTO(objectDef); } @Get(':objectApiName/ui-config') @@ -58,10 +59,12 @@ export class SetupObjectController { @Param('objectApiName') objectApiName: string, @Body() data: any, ) { - return this.objectService.createFieldDefinition( + const field = await this.objectService.createFieldDefinition( tenantId, objectApiName, data, ); + // Map the created field to frontend format + return this.fieldMapperService.mapFieldToDTO(field); } } diff --git a/frontend/components/PageLayoutRenderer.vue b/frontend/components/PageLayoutRenderer.vue index f2e5cb0..4d4eb8e 100644 --- a/frontend/components/PageLayoutRenderer.vue +++ b/frontend/components/PageLayoutRenderer.vue @@ -14,6 +14,7 @@ v-if="fieldItem.field" :field="fieldItem.field" :model-value="modelValue?.[fieldItem.field.apiName]" + :record-data="modelValue" :mode="readonly ? VM.DETAIL : VM.EDIT" @update:model-value="handleFieldUpdate(fieldItem.field.apiName, $event)" /> @@ -30,6 +31,7 @@ diff --git a/frontend/components/fields/FieldRenderer.vue b/frontend/components/fields/FieldRenderer.vue index 921f85b..313c10c 100644 --- a/frontend/components/fields/FieldRenderer.vue +++ b/frontend/components/fields/FieldRenderer.vue @@ -30,10 +30,6 @@ const emit = defineEmits<{ const { api } = useApi() -// For relationship fields, store the related record for display -const relatedRecord = ref(null) -const loadingRelated = ref(false) - const value = computed({ get: () => props.modelValue, set: (val) => emit('update:modelValue', val), @@ -49,80 +45,36 @@ const isRelationshipField = computed(() => { return [FieldType.BELONGS_TO].includes(props.field.type) }) -// Get relation object name (e.g., 'tenants' -> singular 'tenant') +// Get relation object name from field apiName (e.g., 'ownerId' -> 'owner') const getRelationPropertyName = () => { - const relationObject = props.field.relationObject || props.field.apiName.replace('Id', '') - // Convert plural to singular for property name (e.g., 'tenants' -> 'tenant') - return relationObject.endsWith('s') ? relationObject.slice(0, -1) : relationObject -} - -// Fetch related record for display -const fetchRelatedRecord = async () => { - if (!isRelationshipField.value || !props.modelValue) return - - const relationObject = props.field.relationObject || props.field.apiName.replace('Id', '') - const displayField = props.field.relationDisplayField || 'name' - - loadingRelated.value = true - try { - const record = await api.get(`${props.baseUrl}/${relationObject}/${props.modelValue}`) - relatedRecord.value = record - } catch (err) { - console.error('Error fetching related record:', err) - relatedRecord.value = null - } finally { - loadingRelated.value = false - } + // Backend attaches related object using field apiName without 'Id' suffix, lowercase + // e.g., ownerId -> owner, accountId -> account + return props.field.apiName.replace(/Id$/, '').toLowerCase() } // Display value for relationship fields const relationshipDisplayValue = computed(() => { if (!isRelationshipField.value) return props.modelValue || '-' - + // First, check if the parent record data includes the related object // This happens when backend uses .withGraphFetched() if (props.recordData) { const relationPropertyName = getRelationPropertyName() const relatedObject = props.recordData[relationPropertyName] - + if (relatedObject && typeof relatedObject === 'object') { const displayField = props.field.relationDisplayField || 'name' return relatedObject[displayField] || relatedObject.id || props.modelValue } } - // Otherwise use the fetched related record - if (relatedRecord.value) { - const displayField = props.field.relationDisplayField || 'name' - return relatedRecord.value[displayField] || relatedRecord.value.id - } - - // Show loading state - if (loadingRelated.value) { - return 'Loading...' - } - - // Fallback to ID + // If no related object found in recordData, just show the ID + // (The fetch mechanism is removed to avoid N+1 queries) return props.modelValue || '-' }) -// Watch for changes in modelValue for relationship fields -watch(() => props.modelValue, () => { - if (isRelationshipField.value && (isDetailMode.value || isListMode.value)) { - fetchRelatedRecord() - } -}) - -// Load related record on mount if needed -onMounted(() => { - if (isRelationshipField.value && props.modelValue && (isDetailMode.value || isListMode.value)) { - fetchRelatedRecord() - } -}) - const formatValue = (val: any): string => { if (val === null || val === undefined) return '-' - switch (props.field.type) { case FieldType.BELONGS_TO: return relationshipDisplayValue.value @@ -168,6 +120,7 @@ const formatValue = (val: any): string => { {{ formatValue(value) }} diff --git a/frontend/components/fields/LookupField.vue b/frontend/components/fields/LookupField.vue index 0acb501..8dcdbe2 100644 --- a/frontend/components/fields/LookupField.vue +++ b/frontend/components/fields/LookupField.vue @@ -56,7 +56,8 @@ const filteredRecords = computed(() => { const fetchRecords = async () => { loading.value = true try { - const response = await api.get(`${props.baseUrl}/${relationObject.value}`) + const endpoint = `${props.baseUrl}/${relationObject.value}/records` + const response = await api.get(endpoint) records.value = response || [] // If we have a modelValue, find the selected record diff --git a/frontend/components/views/DetailViewEnhanced.vue b/frontend/components/views/DetailViewEnhanced.vue index 96c8b2a..0de4bb8 100644 --- a/frontend/components/views/DetailViewEnhanced.vue +++ b/frontend/components/views/DetailViewEnhanced.vue @@ -19,10 +19,12 @@ interface Props { data: any loading?: boolean objectId?: string // For fetching page layout + baseUrl?: string } const props = withDefaults(defineProps(), { loading: false, + baseUrl: '/runtime/objects', }) const emit = defineEmits<{ @@ -170,6 +172,7 @@ const usePageLayout = computed(() => { :model-value="data[field.apiName]" :record-data="data" :mode="ViewMode.DETAIL" + :base-url="baseUrl" /> @@ -192,6 +195,7 @@ const usePageLayout = computed(() => { :model-value="data[field.apiName]" :record-data="data" :mode="ViewMode.DETAIL" + :base-url="baseUrl" /> diff --git a/frontend/components/views/EditViewEnhanced.vue b/frontend/components/views/EditViewEnhanced.vue index e25d57c..e968653 100644 --- a/frontend/components/views/EditViewEnhanced.vue +++ b/frontend/components/views/EditViewEnhanced.vue @@ -19,12 +19,14 @@ interface Props { loading?: boolean saving?: boolean objectId?: string // For fetching page layout + baseUrl?: string } const props = withDefaults(defineProps(), { data: () => ({}), loading: false, saving: false, + baseUrl: '/runtime/objects', }) const emit = defineEmits<{ @@ -260,6 +262,7 @@ const handleFieldUpdate = (fieldName: string, value: any) => { :model-value="formData[field.apiName]" :mode="ViewMode.EDIT" :error="errors[field.apiName]" + :base-url="baseUrl" @update:model-value="handleFieldUpdate(field.apiName, $event)" /> @@ -283,6 +286,7 @@ const handleFieldUpdate = (fieldName: string, value: any) => { :model-value="formData[field.apiName]" :mode="ViewMode.EDIT" :error="errors[field.apiName]" + :base-url="baseUrl" @update:model-value="handleFieldUpdate(field.apiName, $event)" /> diff --git a/frontend/components/views/ListView.vue b/frontend/components/views/ListView.vue index c47373f..5284e89 100644 --- a/frontend/components/views/ListView.vue +++ b/frontend/components/views/ListView.vue @@ -21,12 +21,14 @@ interface Props { data?: any[] loading?: boolean selectable?: boolean + baseUrl?: string } const props = withDefaults(defineProps(), { data: () => [], loading: false, selectable: false, + baseUrl: '/runtime/objects', }) const emit = defineEmits<{ @@ -207,6 +209,7 @@ const handleAction = (actionId: string) => { :model-value="row[field.apiName]" :record-data="row" :mode="ViewMode.LIST" + :base-url="baseUrl" /> diff --git a/frontend/composables/useFieldViews.ts b/frontend/composables/useFieldViews.ts index d1c208a..6fbed9f 100644 --- a/frontend/composables/useFieldViews.ts +++ b/frontend/composables/useFieldViews.ts @@ -27,35 +27,35 @@ export const useFields = () => { type: fieldDef.type, // Default values - placeholder: fieldDef.uiMetadata?.placeholder || fieldDef.description, - helpText: fieldDef.uiMetadata?.helpText || fieldDef.description, + placeholder: fieldDef.placeholder || fieldDef.description, + helpText: fieldDef.helpText || fieldDef.description, defaultValue: fieldDef.defaultValue, // Validation isRequired: fieldDef.isRequired, - isReadOnly: isAutoGeneratedField || fieldDef.uiMetadata?.isReadOnly, - validationRules: fieldDef.uiMetadata?.validationRules || [], + isReadOnly: isAutoGeneratedField || fieldDef.isReadOnly, + validationRules: fieldDef.validationRules || [], // View options - only hide system and auto-generated fields by default - showOnList: fieldDef.uiMetadata?.showOnList ?? true, - showOnDetail: fieldDef.uiMetadata?.showOnDetail ?? true, - showOnEdit: fieldDef.uiMetadata?.showOnEdit ?? !shouldHideOnEdit, - sortable: fieldDef.uiMetadata?.sortable ?? true, + showOnList: fieldDef.showOnList ?? true, + showOnDetail: fieldDef.showOnDetail ?? true, + showOnEdit: fieldDef.showOnEdit ?? !shouldHideOnEdit, + sortable: fieldDef.sortable ?? true, // Field type specific - options: fieldDef.uiMetadata?.options, - rows: fieldDef.uiMetadata?.rows, - min: fieldDef.uiMetadata?.min, - max: fieldDef.uiMetadata?.max, - step: fieldDef.uiMetadata?.step, - accept: fieldDef.uiMetadata?.accept, - relationObject: fieldDef.referenceObject, - relationDisplayField: fieldDef.uiMetadata?.relationDisplayField, + options: fieldDef.options, + rows: fieldDef.rows, + min: fieldDef.min, + max: fieldDef.max, + step: fieldDef.step, + accept: fieldDef.accept, + relationObject: fieldDef.relationObject, + relationDisplayField: fieldDef.relationDisplayField, // Formatting - format: fieldDef.uiMetadata?.format, - prefix: fieldDef.uiMetadata?.prefix, - suffix: fieldDef.uiMetadata?.suffix, + format: fieldDef.format, + prefix: fieldDef.prefix, + suffix: fieldDef.suffix, // Advanced dependsOn: fieldDef.uiMetadata?.dependsOn, diff --git a/frontend/pages/[objectName]/[[recordId]]/[[view]].vue b/frontend/pages/[objectName]/[[recordId]]/[[view]].vue index d65ee14..82407cc 100644 --- a/frontend/pages/[objectName]/[[recordId]]/[[view]].vue +++ b/frontend/pages/[objectName]/[[recordId]]/[[view]].vue @@ -260,6 +260,7 @@ onMounted(async () => { :config="listConfig" :data="records" :loading="dataLoading" + :base-url="`/runtime/objects`" selectable @row-click="handleRowClick" @create="handleCreate" @@ -274,6 +275,7 @@ onMounted(async () => { :data="currentRecord" :loading="dataLoading" :object-id="objectDefinition?.id" + :base-url="`/runtime/objects`" @edit="handleEdit" @delete="() => handleDelete([currentRecord])" @back="handleBack" @@ -287,6 +289,7 @@ onMounted(async () => { :loading="dataLoading" :saving="saving" :object-id="objectDefinition?.id" + :base-url="`/runtime/objects`" @save="handleSaveRecord" @cancel="handleCancel" @back="handleBack"