From 8e4690c9c9a1b56a97a2865a4ae05fac345693ad Mon Sep 17 00:00:00 2001 From: Francisco Gaona Date: Tue, 6 Jan 2026 09:45:29 +0100 Subject: [PATCH 1/7] WIP - initial iteration on manage fields --- backend/src/object/object.service.ts | 193 +++++++ .../src/object/schema-management.service.ts | 31 ++ backend/src/object/setup-object.controller.ts | 30 + .../fields/FieldAttributesCommon.vue | 195 +++++++ .../components/fields/FieldAttributesType.vue | 296 ++++++++++ .../components/fields/FieldTypeSelector.vue | 140 +++++ frontend/pages/setup/objects/[apiName].vue | 523 ++++++++++++++++-- 7 files changed, 1377 insertions(+), 31 deletions(-) create mode 100644 frontend/components/fields/FieldAttributesCommon.vue create mode 100644 frontend/components/fields/FieldAttributesType.vue create mode 100644 frontend/components/fields/FieldTypeSelector.vue diff --git a/backend/src/object/object.service.ts b/backend/src/object/object.service.ts index 26fec40..eddd8b6 100644 --- a/backend/src/object/object.service.ts +++ b/backend/src/object/object.service.ts @@ -3,6 +3,7 @@ import { TenantDatabaseService } from '../tenant/tenant-database.service'; import { CustomMigrationService } from '../migration/custom-migration.service'; import { ModelService } from './models/model.service'; import { AuthorizationService } from '../rbac/authorization.service'; +import { SchemaManagementService } from './schema-management.service'; import { ObjectDefinition } from '../models/object-definition.model'; import { FieldDefinition } from '../models/field-definition.model'; import { User } from '../models/user.model'; @@ -742,6 +743,198 @@ export class ObjectService { return { success: true }; } + /** + * Update a field definition + * Can update metadata (label, description, placeholder, helpText, etc.) safely + * Cannot update apiName or type if field has existing data (prevent data loss) + */ + async updateFieldDefinition( + tenantId: string, + objectApiName: string, + fieldApiName: string, + data: Partial<{ + label: string; + description: string; + isRequired: boolean; + isUnique: boolean; + defaultValue: string; + placeholder: string; + helpText: string; + displayOrder: number; + uiMetadata: Record; + }>, + ) { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + // Get the object definition + const objectDef = await ObjectDefinition.query(knex) + .findOne({ apiName: objectApiName }); + + if (!objectDef) { + throw new NotFoundException(`Object ${objectApiName} not found`); + } + + // Get the field definition + const field = await knex('field_definitions') + .where({ objectDefinitionId: objectDef.id, apiName: fieldApiName }) + .first(); + + if (!field) { + throw new NotFoundException(`Field ${fieldApiName} not found`); + } + + // Check if this field has data (count records) + const tableName = this.getTableName(objectApiName); + const recordCount = await knex(tableName).count('* as cnt').first(); + const hasData = recordCount && (recordCount.cnt as number) > 0; + + // Prepare update object + const updateData: any = { + updated_at: knex.fn.now(), + }; + + // Always allow these updates + if (data.label !== undefined) updateData.label = data.label; + if (data.description !== undefined) updateData.description = data.description; + if (data.displayOrder !== undefined) updateData.displayOrder = data.displayOrder; + + // Merge with existing uiMetadata + const existingMetadata = field.ui_metadata ? JSON.parse(field.ui_metadata) : {}; + const newMetadata = { ...existingMetadata }; + + if (data.placeholder !== undefined) newMetadata.placeholder = data.placeholder; + if (data.helpText !== undefined) newMetadata.helpText = data.helpText; + if (data.uiMetadata) { + Object.assign(newMetadata, data.uiMetadata); + } + + if (Object.keys(newMetadata).length > 0) { + updateData.ui_metadata = JSON.stringify(newMetadata); + } + + // Conditional updates based on data existence + if (data.isRequired !== undefined) { + if (hasData && data.isRequired && !field.isRequired) { + throw new Error('Cannot make a field required when data exists. Existing records may have null values.'); + } + updateData.isRequired = data.isRequired; + } + + if (data.isUnique !== undefined) { + if (hasData && data.isUnique && !field.isUnique) { + throw new Error('Cannot add unique constraint to field with existing data. Existing records may have duplicate values.'); + } + updateData.isUnique = data.isUnique; + } + + // Update the field definition + await knex('field_definitions') + .where({ id: field.id }) + .update(updateData); + + return knex('field_definitions').where({ id: field.id }).first(); + } + + /** + * Delete a field definition and clean up dependencies + * Removes the column from the physical table + * Removes field references from page layouts + * CASCADE deletion handles role_field_permissions + */ + async deleteFieldDefinition( + tenantId: string, + objectApiName: string, + fieldApiName: string, + ) { + const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); + const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); + + // Get the object definition + const objectDef = await ObjectDefinition.query(knex) + .findOne({ apiName: objectApiName }); + + if (!objectDef) { + throw new NotFoundException(`Object ${objectApiName} not found`); + } + + // Get the field definition + const field = await knex('field_definitions') + .where({ objectDefinitionId: objectDef.id, apiName: fieldApiName }) + .first(); + + if (!field) { + throw new NotFoundException(`Field ${fieldApiName} not found`); + } + + // Prevent deletion of system fields + const systemFieldNames = ['id', 'tenantId', 'ownerId', 'created_at', 'updated_at', 'createdAt', 'updatedAt']; + if (systemFieldNames.includes(fieldApiName)) { + throw new Error(`Cannot delete system field: ${fieldApiName}`); + } + + // Clean up page layouts - remove field references from layoutConfig + const pageLayouts = await knex('page_layouts') + .where({ objectDefinitionId: objectDef.id }); + + for (const layout of pageLayouts) { + const layoutConfig = layout.layout_config ? JSON.parse(layout.layout_config) : { fields: [] }; + + // Filter out any field references for this field + if (layoutConfig.fields) { + layoutConfig.fields = layoutConfig.fields.filter( + (f: any) => f.fieldId !== field.id, + ); + } + + // Update the page layout + await knex('page_layouts') + .where({ id: layout.id }) + .update({ + layout_config: JSON.stringify(layoutConfig), + updated_at: knex.fn.now(), + }); + } + + // Clean up dependsOn references in other fields + const otherFields = await knex('field_definitions') + .where({ objectDefinitionId: objectDef.id }) + .whereNot({ id: field.id }); + + for (const otherField of otherFields) { + const metadata = otherField.ui_metadata ? JSON.parse(otherField.ui_metadata) : {}; + + if (metadata.dependsOn && Array.isArray(metadata.dependsOn)) { + metadata.dependsOn = metadata.dependsOn.filter( + (dep: any) => dep !== field.apiName, + ); + + await knex('field_definitions') + .where({ id: otherField.id }) + .update({ + ui_metadata: JSON.stringify(metadata), + updated_at: knex.fn.now(), + }); + } + } + + // Remove the column from the physical table + const tableName = this.getTableName(objectApiName); + const schemaManagementService = new SchemaManagementService(); + + try { + await schemaManagementService.removeFieldFromTable(knex, objectApiName, fieldApiName); + } catch (error) { + this.logger.warn(`Failed to remove column ${fieldApiName} from table ${tableName}: ${error.message}`); + // Continue with deletion even if column removal fails - field definition must be cleaned up + } + + // Delete the field definition (CASCADE will delete role_field_permissions) + await knex('field_definitions').where({ id: field.id }).delete(); + + return { success: true }; + } + async getFieldPermissions(tenantId: string, objectId: string) { const resolvedTenantId = await this.tenantDbService.resolveTenantId(tenantId); const knex = await this.tenantDbService.getTenantKnexById(resolvedTenantId); diff --git a/backend/src/object/schema-management.service.ts b/backend/src/object/schema-management.service.ts index 7f932b5..4b73305 100644 --- a/backend/src/object/schema-management.service.ts +++ b/backend/src/object/schema-management.service.ts @@ -71,6 +71,37 @@ export class SchemaManagementService { this.logger.log(`Removed field ${fieldApiName} from table ${tableName}`); } + /** + * Alter a field in an existing object table + * Handles safe updates like changing NOT NULL or constraints + * Warns about potentially destructive operations + */ + async alterFieldInTable( + knex: Knex, + objectApiName: string, + fieldApiName: string, + field: FieldDefinition, + options?: { + skipTypeChange?: boolean; // Skip if type change would lose data + }, + ) { + const tableName = this.getTableName(objectApiName); + const skipTypeChange = options?.skipTypeChange ?? true; + + await knex.schema.alterTable(tableName, (table) => { + // Drop the existing column and recreate with new definition + // Note: This approach works for metadata changes, but type changes may need data migration + table.dropColumn(fieldApiName); + }); + + // Recreate the column with new definition + await knex.schema.alterTable(tableName, (table) => { + this.addFieldColumn(table, field); + }); + + this.logger.log(`Altered field ${fieldApiName} in table ${tableName}`); + } + /** * Drop an object table */ diff --git a/backend/src/object/setup-object.controller.ts b/backend/src/object/setup-object.controller.ts index 426376c..5da5f93 100644 --- a/backend/src/object/setup-object.controller.ts +++ b/backend/src/object/setup-object.controller.ts @@ -4,6 +4,7 @@ import { Post, Patch, Put, + Delete, Param, Body, UseGuards, @@ -72,6 +73,35 @@ export class SetupObjectController { return this.fieldMapperService.mapFieldToDTO(field); } + @Put(':objectApiName/fields/:fieldApiName') + async updateFieldDefinition( + @TenantId() tenantId: string, + @Param('objectApiName') objectApiName: string, + @Param('fieldApiName') fieldApiName: string, + @Body() data: any, + ) { + const field = await this.objectService.updateFieldDefinition( + tenantId, + objectApiName, + fieldApiName, + data, + ); + return this.fieldMapperService.mapFieldToDTO(field); + } + + @Delete(':objectApiName/fields/:fieldApiName') + async deleteFieldDefinition( + @TenantId() tenantId: string, + @Param('objectApiName') objectApiName: string, + @Param('fieldApiName') fieldApiName: string, + ) { + return this.objectService.deleteFieldDefinition( + tenantId, + objectApiName, + fieldApiName, + ); + } + @Patch(':objectApiName') async updateObjectDefinition( @TenantId() tenantId: string, diff --git a/frontend/components/fields/FieldAttributesCommon.vue b/frontend/components/fields/FieldAttributesCommon.vue new file mode 100644 index 0000000..072f083 --- /dev/null +++ b/frontend/components/fields/FieldAttributesCommon.vue @@ -0,0 +1,195 @@ +