WIP - use objection and working lookup field to owner

This commit is contained in:
Francisco Gaona
2025-12-24 21:43:58 +01:00
parent 4520f94b69
commit c5305490c1
14 changed files with 334 additions and 112 deletions

View File

@@ -125,6 +125,7 @@ model FieldDefinition {
isSystem Boolean @default(false) isSystem Boolean @default(false)
isCustom Boolean @default(true) isCustom Boolean @default(true)
displayOrder Int @default(0) displayOrder Int @default(0)
uiMetadata Json? @map("ui_metadata")
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")

View File

@@ -51,13 +51,29 @@ export class FieldMapperService {
* Convert a field definition from the database to a frontend-friendly FieldConfig * Convert a field definition from the database to a frontend-friendly FieldConfig
*/ */
mapFieldToDTO(field: any): FieldConfigDTO { mapFieldToDTO(field: any): FieldConfigDTO {
const uiMetadata = field.uiMetadata || {}; // Parse ui_metadata if it's a JSON string or object
let uiMetadata: any = {};
const metadataField = field.ui_metadata || field.uiMetadata;
if (metadataField) {
if (typeof metadataField === 'string') {
try {
uiMetadata = JSON.parse(metadataField);
} catch (e) {
uiMetadata = {};
}
} else {
uiMetadata = metadataField;
}
}
const frontendType = this.mapFieldType(field.type);
const isLookupField = frontendType === 'belongsTo' || field.type.toLowerCase().includes('lookup');
return { return {
id: field.id, id: field.id,
apiName: field.apiName, apiName: field.apiName,
label: field.label, label: field.label,
type: this.mapFieldType(field.type), type: frontendType,
// Display properties // Display properties
placeholder: uiMetadata.placeholder || field.description, placeholder: uiMetadata.placeholder || field.description,
@@ -82,7 +98,10 @@ export class FieldMapperService {
step: uiMetadata.step, step: uiMetadata.step,
accept: uiMetadata.accept, accept: uiMetadata.accept,
relationObject: field.referenceObject, relationObject: field.referenceObject,
relationDisplayField: uiMetadata.relationDisplayField, // For lookup fields, provide default display field if not specified
relationDisplayField: isLookupField
? (uiMetadata.relationDisplayField || 'name')
: uiMetadata.relationDisplayField,
// Formatting // Formatting
format: uiMetadata.format, format: uiMetadata.format,

View File

@@ -30,8 +30,13 @@ export interface ObjectMetadata {
export class DynamicModelFactory { export class DynamicModelFactory {
/** /**
* Create a dynamic model class from object metadata * Create a dynamic model class from object metadata
* @param meta Object metadata
* @param getModel Function to retrieve model classes from registry
*/ */
static createModel(meta: ObjectMetadata): ModelClass<any> { static createModel(
meta: ObjectMetadata,
getModel?: (apiName: string) => ModelClass<any>,
): ModelClass<any> {
const { tableName, fields, apiName, relations = [] } = meta; const { tableName, fields, apiName, relations = [] } = meta;
// Build JSON schema properties // Build JSON schema properties
@@ -57,12 +62,16 @@ export class DynamicModelFactory {
} }
} }
// Build relation mappings // Build relation mappings from lookup fields
const relationMappings: RelationMappings = {}; const lookupFields = fields.filter(f => f.type === 'LOOKUP' && f.referenceObject);
for (const rel of relations) {
// Relations are resolved dynamically, skipping for now // Store lookup fields metadata for later use
// Will be handled by ModelRegistry.getModel() const lookupFieldsInfo = lookupFields.map(f => ({
} apiName: f.apiName,
relationName: f.apiName.replace(/Id$/, '').toLowerCase(),
referenceObject: f.referenceObject,
targetTable: this.getTableName(f.referenceObject),
}));
// Create the dynamic model class extending Model directly // Create the dynamic model class extending Model directly
class DynamicModel extends Model { class DynamicModel extends Model {
@@ -77,7 +86,40 @@ export class DynamicModelFactory {
static objectApiName = apiName; static objectApiName = apiName;
static relationMappings = relationMappings; static lookupFields = lookupFieldsInfo;
static get relationMappings(): RelationMappings {
const mappings: RelationMappings = {};
// Build relation mappings from lookup fields
for (const lookupInfo of lookupFieldsInfo) {
// Use getModel function if provided, otherwise use string reference
let modelClass: any = lookupInfo.referenceObject;
if (getModel) {
const resolvedModel = getModel(lookupInfo.referenceObject);
// Only use resolved model if it exists, otherwise skip this relation
// It will be resolved later when the model is registered
if (resolvedModel) {
modelClass = resolvedModel;
} else {
// Skip this relation if model not found yet
continue;
}
}
mappings[lookupInfo.relationName] = {
relation: Model.BelongsToOneRelation,
modelClass,
join: {
from: `${tableName}.${lookupInfo.apiName}`,
to: `${lookupInfo.targetTable}.id`,
},
};
}
return mappings;
}
static get jsonSchema() { static get jsonSchema() {
return { return {
@@ -159,4 +201,16 @@ export class DynamicModelFactory {
return { type: 'string' }; return { type: 'string' };
} }
} }
/**
* Get table name from object API name
*/
private static getTableName(objectApiName: string): string {
// Convert PascalCase/camelCase to snake_case and pluralize
const snakeCase = objectApiName
.replace(/([A-Z])/g, '_$1')
.toLowerCase()
.replace(/^_/, '');
return snakeCase.endsWith('s') ? snakeCase : `${snakeCase}s`;
}
} }

View File

@@ -42,7 +42,12 @@ export class ModelRegistry {
createAndRegisterModel( createAndRegisterModel(
metadata: ObjectMetadata, metadata: ObjectMetadata,
): ModelClass<BaseModel> { ): ModelClass<BaseModel> {
const model = DynamicModelFactory.createModel(metadata); // Create model with a getModel function that resolves from this registry
// Returns undefined if model not found (for models not yet registered)
const model = DynamicModelFactory.createModel(
metadata,
(apiName: string) => this.registry.get(apiName),
);
this.registerModel(metadata.apiName, model); this.registerModel(metadata.apiName, model);
return model; return model;
} }

View File

@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException, Logger } from '@nestjs/common';
import { TenantDatabaseService } from '../tenant/tenant-database.service'; import { TenantDatabaseService } from '../tenant/tenant-database.service';
import { CustomMigrationService } from '../migration/custom-migration.service'; import { CustomMigrationService } from '../migration/custom-migration.service';
import { ModelService } from './models/model.service'; import { ModelService } from './models/model.service';
@@ -6,6 +6,8 @@ import { ObjectMetadata } from './models/dynamic-model.factory';
@Injectable() @Injectable()
export class ObjectService { export class ObjectService {
private readonly logger = new Logger(ObjectService.name);
constructor( constructor(
private tenantDbService: TenantDatabaseService, private tenantDbService: TenantDatabaseService,
private customMigrationService: CustomMigrationService, private customMigrationService: CustomMigrationService,
@@ -107,14 +109,14 @@ export class ObjectService {
description: 'The user who owns this record', description: 'The user who owns this record',
isRequired: false, // Auto-set by system isRequired: false, // Auto-set by system
isUnique: false, isUnique: false,
referenceObject: null, referenceObject: 'User',
isSystem: true, isSystem: true,
isCustom: false, isCustom: false,
}, },
{ {
apiName: 'name', apiName: 'name',
label: 'Name', label: 'Name',
type: 'TEXT', type: 'STRING',
description: 'The primary name field for this record', description: 'The primary name field for this record',
isRequired: false, // Optional field isRequired: false, // Optional field
isUnique: false, isUnique: false,
@@ -156,14 +158,23 @@ export class ObjectService {
.first(); .first();
if (!existingField) { if (!existingField) {
await knex('field_definitions').insert({ const fieldData: any = {
id: knex.raw('(UUID())'), id: knex.raw('(UUID())'),
objectDefinitionId: objectDef.id, objectDefinitionId: objectDef.id,
...field, ...field,
created_at: knex.fn.now(), created_at: knex.fn.now(),
updated_at: knex.fn.now(), updated_at: knex.fn.now(),
};
// For lookup fields, set ui_metadata with relationDisplayField
if (field.type === 'LOOKUP') {
fieldData.ui_metadata = JSON.stringify({
relationDisplayField: 'name',
}); });
} }
await knex('field_definitions').insert(fieldData);
}
} }
// Create a migration to create the table // Create a migration to create the table
@@ -226,6 +237,8 @@ export class ObjectService {
isRequired?: boolean; isRequired?: boolean;
isUnique?: boolean; isUnique?: boolean;
referenceObject?: string; referenceObject?: string;
relationObject?: string;
relationDisplayField?: string;
defaultValue?: string; defaultValue?: string;
}, },
) { ) {
@@ -233,13 +246,35 @@ export class ObjectService {
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
const obj = await this.getObjectDefinition(tenantId, objectApiName); const obj = await this.getObjectDefinition(tenantId, objectApiName);
const [id] = await knex('field_definitions').insert({ // Convert frontend type to database type
const dbFieldType = this.convertFrontendFieldType(data.type);
// Use relationObject if provided (alias for referenceObject)
const referenceObject = data.referenceObject || data.relationObject;
const fieldData: any = {
id: knex.raw('(UUID())'), id: knex.raw('(UUID())'),
objectDefinitionId: obj.id, objectDefinitionId: obj.id,
...data, apiName: data.apiName,
label: data.label,
type: dbFieldType,
description: data.description,
isRequired: data.isRequired ?? false,
isUnique: data.isUnique ?? false,
referenceObject: referenceObject,
defaultValue: data.defaultValue,
created_at: knex.fn.now(), created_at: knex.fn.now(),
updated_at: knex.fn.now(), updated_at: knex.fn.now(),
};
// Store relationDisplayField in UI metadata if provided
if (data.relationDisplayField) {
fieldData.ui_metadata = JSON.stringify({
relationDisplayField: data.relationDisplayField,
}); });
}
const [id] = await knex('field_definitions').insert(fieldData);
return knex('field_definitions').where({ id }).first(); return knex('field_definitions').where({ id }).first();
} }
@@ -279,6 +314,39 @@ export class ObjectService {
}; };
} }
/**
* Convert frontend field type to database field type
*/
private convertFrontendFieldType(frontendType: string): string {
const typeMap: Record<string, string> = {
'text': 'TEXT',
'textarea': 'LONG_TEXT',
'password': 'TEXT',
'email': 'EMAIL',
'number': 'NUMBER',
'currency': 'CURRENCY',
'percent': 'PERCENT',
'select': 'PICKLIST',
'multiSelect': 'MULTI_PICKLIST',
'boolean': 'BOOLEAN',
'date': 'DATE',
'datetime': 'DATE_TIME',
'time': 'TIME',
'url': 'URL',
'color': 'TEXT',
'json': 'JSON',
'belongsTo': 'LOOKUP',
'hasMany': 'LOOKUP',
'manyToMany': 'LOOKUP',
'markdown': 'LONG_TEXT',
'code': 'LONG_TEXT',
'file': 'FILE',
'image': 'IMAGE',
};
return typeMap[frontendType] || 'TEXT';
}
// Runtime endpoints - CRUD operations // Runtime endpoints - CRUD operations
async getRecords( async getRecords(
tenantId: string, tenantId: string,
@@ -289,8 +357,8 @@ export class ObjectService {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Verify object exists // Verify object exists and get field definitions
await this.getObjectDefinition(tenantId, objectApiName); const objectDef = await this.getObjectDefinition(tenantId, objectApiName);
const tableName = this.getTableName(objectApiName); const tableName = this.getTableName(objectApiName);
@@ -301,6 +369,23 @@ export class ObjectService {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
let query = boundModel.query(); let query = boundModel.query();
// Build graph expression for lookup fields
const lookupFields = objectDef.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject
) || [];
if (lookupFields.length > 0) {
// Build relation expression - use singular lowercase for relation name
const relationExpression = lookupFields
.map(f => f.apiName.replace(/Id$/, '').toLowerCase())
.filter(Boolean)
.join(', ');
if (relationExpression) {
query = query.withGraphFetched(`[${relationExpression}]`);
}
}
// Add ownership filter if ownerId field exists // Add ownership filter if ownerId field exists
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner) { if (hasOwner) {
@@ -315,15 +400,16 @@ export class ObjectService {
return query.select('*'); return query.select('*');
} }
} catch (error) { } catch (error) {
console.warn(`Could not use Objection model for ${objectApiName}:`, error.message); this.logger.warn(`Could not use Objection model for ${objectApiName}, falling back to manual join: ${error.message}`);
} }
// Fallback to raw Knex // Fallback to manual data hydration
let query = knex(tableName); let query = knex(tableName);
// Add ownership filter if ownerId field exists
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner) { if (hasOwner) {
query = query.where({ ownerId: userId }); query = query.where({ [`${tableName}.ownerId`]: userId });
} }
// Apply additional filters // Apply additional filters
@@ -331,7 +417,49 @@ export class ObjectService {
query = query.where(filters); query = query.where(filters);
} }
return query.select('*'); // Get base records
const records = await query.select(`${tableName}.*`);
// Fetch and attach related records for lookup fields
const lookupFields = objectDef.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject
) || [];
if (lookupFields.length > 0 && records.length > 0) {
for (const field of lookupFields) {
const relationName = field.apiName.replace(/Id$/, '').toLowerCase();
const relatedTable = this.getTableName(field.referenceObject);
// Get unique IDs to fetch
const relatedIds = [...new Set(
records
.map(r => r[field.apiName])
.filter(Boolean)
)];
if (relatedIds.length > 0) {
// Fetch all related records in one query
const relatedRecords = await knex(relatedTable)
.whereIn('id', relatedIds)
.select('*');
// Create a map for quick lookup
const relatedMap = new Map(
relatedRecords.map(r => [r.id, r])
);
// Attach related records to main records
for (const record of records) {
const relatedId = record[field.apiName];
if (relatedId && relatedMap.has(relatedId)) {
record[relationName] = relatedMap.get(relatedId);
}
}
}
}
}
return records;
} }
async getRecord( async getRecord(
@@ -343,8 +471,8 @@ export class ObjectService {
const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId);
const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId);
// Verify object exists // Verify object exists and get field definitions
await this.getObjectDefinition(tenantId, objectApiName); const objectDef = await this.getObjectDefinition(tenantId, objectApiName);
const tableName = this.getTableName(objectApiName); const tableName = this.getTableName(objectApiName);
@@ -355,6 +483,23 @@ export class ObjectService {
const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName); const boundModel = await this.modelService.getBoundModel(resolvedTenantId, objectApiName);
let query = boundModel.query().where({ id: recordId }); let query = boundModel.query().where({ id: recordId });
// Build graph expression for lookup fields
const lookupFields = objectDef.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject
) || [];
if (lookupFields.length > 0) {
// Build relation expression - use singular lowercase for relation name
const relationExpression = lookupFields
.map(f => f.apiName.replace(/Id$/, '').toLowerCase())
.filter(Boolean)
.join(', ');
if (relationExpression) {
query = query.withGraphFetched(`[${relationExpression}]`);
}
}
// Add ownership filter if ownerId field exists // Add ownership filter if ownerId field exists
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner) { if (hasOwner) {
@@ -368,15 +513,16 @@ export class ObjectService {
return record; return record;
} }
} catch (error) { } catch (error) {
console.warn(`Could not use Objection model for ${objectApiName}:`, error.message); this.logger.warn(`Could not use Objection model for ${objectApiName}, falling back to manual join: ${error.message}`);
} }
// Fallback to raw Knex // Fallback to manual data hydration
let query = knex(tableName).where({ id: recordId }); let query = knex(tableName).where({ [`${tableName}.id`]: recordId });
// Add ownership filter if ownerId field exists
const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId'); const hasOwner = await knex.schema.hasColumn(tableName, 'ownerId');
if (hasOwner) { if (hasOwner) {
query = query.where({ ownerId: userId }); query = query.where({ [`${tableName}.ownerId`]: userId });
} }
const record = await query.first(); const record = await query.first();
@@ -385,6 +531,30 @@ export class ObjectService {
throw new NotFoundException('Record not found'); throw new NotFoundException('Record not found');
} }
// Fetch and attach related records for lookup fields
const lookupFields = objectDef.fields?.filter(f =>
f.type === 'LOOKUP' && f.referenceObject
) || [];
if (lookupFields.length > 0) {
for (const field of lookupFields) {
const relationName = field.apiName.replace(/Id$/, '').toLowerCase();
const relatedTable = this.getTableName(field.referenceObject);
const relatedId = record[field.apiName];
if (relatedId) {
// Fetch the related record
const relatedRecord = await knex(relatedTable)
.where({ id: relatedId })
.first();
if (relatedRecord) {
record[relationName] = relatedRecord;
}
}
}
}
return record; return record;
} }

View File

@@ -29,7 +29,8 @@ export class SetupObjectController {
@TenantId() tenantId: string, @TenantId() tenantId: string,
@Param('objectApiName') objectApiName: string, @Param('objectApiName') objectApiName: string,
) { ) {
return this.objectService.getObjectDefinition(tenantId, objectApiName); const objectDef = await this.objectService.getObjectDefinition(tenantId, objectApiName);
return this.fieldMapperService.mapObjectDefinitionToDTO(objectDef);
} }
@Get(':objectApiName/ui-config') @Get(':objectApiName/ui-config')
@@ -58,10 +59,12 @@ export class SetupObjectController {
@Param('objectApiName') objectApiName: string, @Param('objectApiName') objectApiName: string,
@Body() data: any, @Body() data: any,
) { ) {
return this.objectService.createFieldDefinition( const field = await this.objectService.createFieldDefinition(
tenantId, tenantId,
objectApiName, objectApiName,
data, data,
); );
// Map the created field to frontend format
return this.fieldMapperService.mapFieldToDTO(field);
} }
} }

View File

@@ -14,6 +14,7 @@
v-if="fieldItem.field" v-if="fieldItem.field"
:field="fieldItem.field" :field="fieldItem.field"
:model-value="modelValue?.[fieldItem.field.apiName]" :model-value="modelValue?.[fieldItem.field.apiName]"
:record-data="modelValue"
:mode="readonly ? VM.DETAIL : VM.EDIT" :mode="readonly ? VM.DETAIL : VM.EDIT"
@update:model-value="handleFieldUpdate(fieldItem.field.apiName, $event)" @update:model-value="handleFieldUpdate(fieldItem.field.apiName, $event)"
/> />
@@ -30,6 +31,7 @@
<FieldRenderer <FieldRenderer
:field="field" :field="field"
:model-value="modelValue?.[field.apiName]" :model-value="modelValue?.[field.apiName]"
:record-data="modelValue"
:mode="readonly ? VM.DETAIL : VM.EDIT" :mode="readonly ? VM.DETAIL : VM.EDIT"
@update:model-value="handleFieldUpdate(field.apiName, $event)" @update:model-value="handleFieldUpdate(field.apiName, $event)"
/> />

View File

@@ -30,10 +30,6 @@ const emit = defineEmits<{
const { api } = useApi() const { api } = useApi()
// For relationship fields, store the related record for display
const relatedRecord = ref<any | null>(null)
const loadingRelated = ref(false)
const value = computed({ const value = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (val) => emit('update:modelValue', val), set: (val) => emit('update:modelValue', val),
@@ -49,30 +45,11 @@ const isRelationshipField = computed(() => {
return [FieldType.BELONGS_TO].includes(props.field.type) return [FieldType.BELONGS_TO].includes(props.field.type)
}) })
// Get relation object name (e.g., 'tenants' -> singular 'tenant') // Get relation object name from field apiName (e.g., 'ownerId' -> 'owner')
const getRelationPropertyName = () => { const getRelationPropertyName = () => {
const relationObject = props.field.relationObject || props.field.apiName.replace('Id', '') // Backend attaches related object using field apiName without 'Id' suffix, lowercase
// Convert plural to singular for property name (e.g., 'tenants' -> 'tenant') // e.g., ownerId -> owner, accountId -> account
return relationObject.endsWith('s') ? relationObject.slice(0, -1) : relationObject return props.field.apiName.replace(/Id$/, '').toLowerCase()
}
// Fetch related record for display
const fetchRelatedRecord = async () => {
if (!isRelationshipField.value || !props.modelValue) return
const relationObject = props.field.relationObject || props.field.apiName.replace('Id', '')
const displayField = props.field.relationDisplayField || 'name'
loadingRelated.value = true
try {
const record = await api.get(`${props.baseUrl}/${relationObject}/${props.modelValue}`)
relatedRecord.value = record
} catch (err) {
console.error('Error fetching related record:', err)
relatedRecord.value = null
} finally {
loadingRelated.value = false
}
} }
// Display value for relationship fields // Display value for relationship fields
@@ -91,38 +68,13 @@ const relationshipDisplayValue = computed(() => {
} }
} }
// Otherwise use the fetched related record // If no related object found in recordData, just show the ID
if (relatedRecord.value) { // (The fetch mechanism is removed to avoid N+1 queries)
const displayField = props.field.relationDisplayField || 'name'
return relatedRecord.value[displayField] || relatedRecord.value.id
}
// Show loading state
if (loadingRelated.value) {
return 'Loading...'
}
// Fallback to ID
return props.modelValue || '-' return props.modelValue || '-'
}) })
// Watch for changes in modelValue for relationship fields
watch(() => props.modelValue, () => {
if (isRelationshipField.value && (isDetailMode.value || isListMode.value)) {
fetchRelatedRecord()
}
})
// Load related record on mount if needed
onMounted(() => {
if (isRelationshipField.value && props.modelValue && (isDetailMode.value || isListMode.value)) {
fetchRelatedRecord()
}
})
const formatValue = (val: any): string => { const formatValue = (val: any): string => {
if (val === null || val === undefined) return '-' if (val === null || val === undefined) return '-'
switch (props.field.type) { switch (props.field.type) {
case FieldType.BELONGS_TO: case FieldType.BELONGS_TO:
return relationshipDisplayValue.value return relationshipDisplayValue.value
@@ -168,6 +120,7 @@ const formatValue = (val: any): string => {
{{ formatValue(value) }} {{ formatValue(value) }}
</Badge> </Badge>
<template v-else> <template v-else>
{{ formatValue(value) }} {{ formatValue(value) }}
</template> </template>
</div> </div>

View File

@@ -56,7 +56,8 @@ const filteredRecords = computed(() => {
const fetchRecords = async () => { const fetchRecords = async () => {
loading.value = true loading.value = true
try { try {
const response = await api.get(`${props.baseUrl}/${relationObject.value}`) const endpoint = `${props.baseUrl}/${relationObject.value}/records`
const response = await api.get(endpoint)
records.value = response || [] records.value = response || []
// If we have a modelValue, find the selected record // If we have a modelValue, find the selected record

View File

@@ -19,10 +19,12 @@ interface Props {
data: any data: any
loading?: boolean loading?: boolean
objectId?: string // For fetching page layout objectId?: string // For fetching page layout
baseUrl?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
loading: false, loading: false,
baseUrl: '/runtime/objects',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -170,6 +172,7 @@ const usePageLayout = computed(() => {
:model-value="data[field.apiName]" :model-value="data[field.apiName]"
:record-data="data" :record-data="data"
:mode="ViewMode.DETAIL" :mode="ViewMode.DETAIL"
:base-url="baseUrl"
/> />
</div> </div>
</CardContent> </CardContent>
@@ -192,6 +195,7 @@ const usePageLayout = computed(() => {
:model-value="data[field.apiName]" :model-value="data[field.apiName]"
:record-data="data" :record-data="data"
:mode="ViewMode.DETAIL" :mode="ViewMode.DETAIL"
:base-url="baseUrl"
/> />
</div> </div>
</CardContent> </CardContent>

View File

@@ -19,12 +19,14 @@ interface Props {
loading?: boolean loading?: boolean
saving?: boolean saving?: boolean
objectId?: string // For fetching page layout objectId?: string // For fetching page layout
baseUrl?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
data: () => ({}), data: () => ({}),
loading: false, loading: false,
saving: false, saving: false,
baseUrl: '/runtime/objects',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -260,6 +262,7 @@ const handleFieldUpdate = (fieldName: string, value: any) => {
:model-value="formData[field.apiName]" :model-value="formData[field.apiName]"
:mode="ViewMode.EDIT" :mode="ViewMode.EDIT"
:error="errors[field.apiName]" :error="errors[field.apiName]"
:base-url="baseUrl"
@update:model-value="handleFieldUpdate(field.apiName, $event)" @update:model-value="handleFieldUpdate(field.apiName, $event)"
/> />
</div> </div>
@@ -283,6 +286,7 @@ const handleFieldUpdate = (fieldName: string, value: any) => {
:model-value="formData[field.apiName]" :model-value="formData[field.apiName]"
:mode="ViewMode.EDIT" :mode="ViewMode.EDIT"
:error="errors[field.apiName]" :error="errors[field.apiName]"
:base-url="baseUrl"
@update:model-value="handleFieldUpdate(field.apiName, $event)" @update:model-value="handleFieldUpdate(field.apiName, $event)"
/> />
</div> </div>

View File

@@ -21,12 +21,14 @@ interface Props {
data?: any[] data?: any[]
loading?: boolean loading?: boolean
selectable?: boolean selectable?: boolean
baseUrl?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
data: () => [], data: () => [],
loading: false, loading: false,
selectable: false, selectable: false,
baseUrl: '/runtime/objects',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
@@ -207,6 +209,7 @@ const handleAction = (actionId: string) => {
:model-value="row[field.apiName]" :model-value="row[field.apiName]"
:record-data="row" :record-data="row"
:mode="ViewMode.LIST" :mode="ViewMode.LIST"
:base-url="baseUrl"
/> />
</TableCell> </TableCell>
<TableCell @click.stop> <TableCell @click.stop>

View File

@@ -27,35 +27,35 @@ export const useFields = () => {
type: fieldDef.type, type: fieldDef.type,
// Default values // Default values
placeholder: fieldDef.uiMetadata?.placeholder || fieldDef.description, placeholder: fieldDef.placeholder || fieldDef.description,
helpText: fieldDef.uiMetadata?.helpText || fieldDef.description, helpText: fieldDef.helpText || fieldDef.description,
defaultValue: fieldDef.defaultValue, defaultValue: fieldDef.defaultValue,
// Validation // Validation
isRequired: fieldDef.isRequired, isRequired: fieldDef.isRequired,
isReadOnly: isAutoGeneratedField || fieldDef.uiMetadata?.isReadOnly, isReadOnly: isAutoGeneratedField || fieldDef.isReadOnly,
validationRules: fieldDef.uiMetadata?.validationRules || [], validationRules: fieldDef.validationRules || [],
// View options - only hide system and auto-generated fields by default // View options - only hide system and auto-generated fields by default
showOnList: fieldDef.uiMetadata?.showOnList ?? true, showOnList: fieldDef.showOnList ?? true,
showOnDetail: fieldDef.uiMetadata?.showOnDetail ?? true, showOnDetail: fieldDef.showOnDetail ?? true,
showOnEdit: fieldDef.uiMetadata?.showOnEdit ?? !shouldHideOnEdit, showOnEdit: fieldDef.showOnEdit ?? !shouldHideOnEdit,
sortable: fieldDef.uiMetadata?.sortable ?? true, sortable: fieldDef.sortable ?? true,
// Field type specific // Field type specific
options: fieldDef.uiMetadata?.options, options: fieldDef.options,
rows: fieldDef.uiMetadata?.rows, rows: fieldDef.rows,
min: fieldDef.uiMetadata?.min, min: fieldDef.min,
max: fieldDef.uiMetadata?.max, max: fieldDef.max,
step: fieldDef.uiMetadata?.step, step: fieldDef.step,
accept: fieldDef.uiMetadata?.accept, accept: fieldDef.accept,
relationObject: fieldDef.referenceObject, relationObject: fieldDef.relationObject,
relationDisplayField: fieldDef.uiMetadata?.relationDisplayField, relationDisplayField: fieldDef.relationDisplayField,
// Formatting // Formatting
format: fieldDef.uiMetadata?.format, format: fieldDef.format,
prefix: fieldDef.uiMetadata?.prefix, prefix: fieldDef.prefix,
suffix: fieldDef.uiMetadata?.suffix, suffix: fieldDef.suffix,
// Advanced // Advanced
dependsOn: fieldDef.uiMetadata?.dependsOn, dependsOn: fieldDef.uiMetadata?.dependsOn,

View File

@@ -260,6 +260,7 @@ onMounted(async () => {
:config="listConfig" :config="listConfig"
:data="records" :data="records"
:loading="dataLoading" :loading="dataLoading"
:base-url="`/runtime/objects`"
selectable selectable
@row-click="handleRowClick" @row-click="handleRowClick"
@create="handleCreate" @create="handleCreate"
@@ -274,6 +275,7 @@ onMounted(async () => {
:data="currentRecord" :data="currentRecord"
:loading="dataLoading" :loading="dataLoading"
:object-id="objectDefinition?.id" :object-id="objectDefinition?.id"
:base-url="`/runtime/objects`"
@edit="handleEdit" @edit="handleEdit"
@delete="() => handleDelete([currentRecord])" @delete="() => handleDelete([currentRecord])"
@back="handleBack" @back="handleBack"
@@ -287,6 +289,7 @@ onMounted(async () => {
:loading="dataLoading" :loading="dataLoading"
:saving="saving" :saving="saving"
:object-id="objectDefinition?.id" :object-id="objectDefinition?.id"
:base-url="`/runtime/objects`"
@save="handleSaveRecord" @save="handleSaveRecord"
@cancel="handleCancel" @cancel="handleCancel"
@back="handleBack" @back="handleBack"