diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts index 28c2455c3d..84cd0ff542 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleRecordGroupField.ts @@ -73,6 +73,20 @@ export const useHandleRecordGroupField = ({ }) satisfies ViewGroup, ); + if ( + !existingGroupKeys.has(`${fieldMetadataItem.id}:`) && + fieldMetadataItem.isNullable === true + ) { + viewGroupsToCreate.push({ + __typename: 'ViewGroup', + id: v4(), + fieldValue: '', + isVisible: true, + position: fieldMetadataItem.options.length, + fieldMetadataId: fieldMetadataItem.id, + } satisfies ViewGroup); + } + const viewGroupsToDelete = view.viewGroups.filter( (group) => group.fieldMetadataId !== fieldMetadataItem.id, ); diff --git a/packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts b/packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts index 523dc70488..6a08a970ff 100644 --- a/packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts +++ b/packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts @@ -41,46 +41,25 @@ export const mapViewGroupsToRecordGroupDefinitions = ({ (option) => option.value === viewGroup.fieldValue, ); - if (!selectedOption) { + if (!selectedOption && selectFieldMetadataItem.isNullable === false) { return null; } return { id: viewGroup.id, fieldMetadataId: viewGroup.fieldMetadataId, - type: RecordGroupDefinitionType.Value, - title: selectedOption.label, - value: selectedOption.value, - color: selectedOption.color, + type: !isDefined(selectedOption) + ? RecordGroupDefinitionType.NoValue + : RecordGroupDefinitionType.Value, + title: selectedOption?.label ?? 'No Value', + value: selectedOption?.value ?? null, + color: selectedOption?.color ?? 'transparent', position: viewGroup.position, isVisible: viewGroup.isVisible, } as RecordGroupDefinition; }) .filter(isDefined); - if (selectFieldMetadataItem.isNullable === true) { - const viewGroup = viewGroups.find( - (viewGroup) => viewGroup.fieldValue === '', - ); - - const noValueColumn = { - id: viewGroup?.id ?? '20202020-c05f-46c9-ae1e-2b3c5c702049', - title: 'No Value', - type: RecordGroupDefinitionType.NoValue, - value: null, - position: - viewGroup?.position ?? - recordGroupDefinitionsFromViewGroups - .map((option) => option.position) - .reduce((a, b) => Math.max(a, b), 0) + 1, - isVisible: viewGroup?.isVisible ?? true, - fieldMetadataId: selectFieldMetadataItem.id, - color: 'transparent', - } satisfies RecordGroupDefinition; - - return [...recordGroupDefinitionsFromViewGroups, noValueColumn]; - } - return recordGroupDefinitionsFromViewGroups.sort( (a, b) => a.position - b.position, ); diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.command.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.command.ts index 53001ebffe..767c1486ec 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.command.ts @@ -6,6 +6,7 @@ import { Repository } from 'typeorm'; import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command'; import { BaseCommandOptions } from 'src/database/commands/base.command'; import { RecordPositionBackfillCommand } from 'src/database/commands/upgrade-version/0-40/0-40-record-position-backfill.command'; +import { ViewGroupNoValueBackfillCommand } from 'src/database/commands/upgrade-version/0-40/0-40-view-group-no-value-backfill.command'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; import { SyncWorkspaceMetadataCommand } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/sync-workspace-metadata.command'; @@ -18,6 +19,7 @@ export class UpgradeTo0_40Command extends ActiveWorkspacesCommandRunner { @InjectRepository(Workspace, 'core') protected readonly workspaceRepository: Repository, private readonly recordPositionBackfillCommand: RecordPositionBackfillCommand, + private readonly viewGroupNoValueBackfillCommand: ViewGroupNoValueBackfillCommand, private readonly syncWorkspaceMetadataCommand: SyncWorkspaceMetadataCommand, ) { super(workspaceRepository); @@ -34,6 +36,12 @@ export class UpgradeTo0_40Command extends ActiveWorkspacesCommandRunner { workspaceIds, ); + await this.viewGroupNoValueBackfillCommand.executeActiveWorkspacesCommand( + passedParam, + options, + workspaceIds, + ); + await this.syncWorkspaceMetadataCommand.executeActiveWorkspacesCommand( passedParam, { diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.module.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.module.ts index f985a361fb..ccd92107f6 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-upgrade-version.module.ts @@ -3,16 +3,25 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { RecordPositionBackfillCommand } from 'src/database/commands/upgrade-version/0-40/0-40-record-position-backfill.command'; import { UpgradeTo0_40Command } from 'src/database/commands/upgrade-version/0-40/0-40-upgrade-version.command'; +import { ViewGroupNoValueBackfillCommand } from 'src/database/commands/upgrade-version/0-40/0-40-view-group-no-value-backfill.command'; import { RecordPositionBackfillModule } from 'src/engine/api/graphql/workspace-query-runner/services/record-position-backfill-module'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module'; import { WorkspaceSyncMetadataCommandsModule } from 'src/engine/workspace-manager/workspace-sync-metadata/commands/workspace-sync-metadata-commands.module'; @Module({ imports: [ TypeOrmModule.forFeature([Workspace], 'core'), + TypeOrmModule.forFeature([FieldMetadataEntity], 'metadata'), WorkspaceSyncMetadataCommandsModule, RecordPositionBackfillModule, + FieldMetadataModule, + ], + providers: [ + UpgradeTo0_40Command, + RecordPositionBackfillCommand, + ViewGroupNoValueBackfillCommand, ], - providers: [UpgradeTo0_40Command, RecordPositionBackfillCommand], }) export class UpgradeTo0_40CommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-view-group-no-value-backfill.command.ts b/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-view-group-no-value-backfill.command.ts new file mode 100644 index 0000000000..ed66287ff5 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version/0-40/0-40-view-group-no-value-backfill.command.ts @@ -0,0 +1,82 @@ +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { Repository } from 'typeorm'; + +import { ActiveWorkspacesCommandRunner } from 'src/database/commands/active-workspaces.command'; +import { BaseCommandOptions } from 'src/database/commands/base.command'; +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { FieldMetadataRelatedRecordsService } from 'src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service'; +import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; +import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity'; +import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity'; + +@Command({ + name: 'migrate-0.40:backfill-view-group-no-value', + description: 'Backfill view group no value', +}) +export class ViewGroupNoValueBackfillCommand extends ActiveWorkspacesCommandRunner { + constructor( + @InjectRepository(Workspace, 'core') + protected readonly workspaceRepository: Repository, + @InjectRepository(FieldMetadataEntity, 'metadata') + private readonly fieldMetadataRepository: Repository, + private readonly twentyORMGlobalManager: TwentyORMGlobalManager, + private readonly fieldMetadataRelatedRecordsService: FieldMetadataRelatedRecordsService, + ) { + super(workspaceRepository); + } + + async executeActiveWorkspacesCommand( + _passedParam: string[], + _options: BaseCommandOptions, + workspaceIds: string[], + ): Promise { + for (const workspaceId of workspaceIds) { + const viewRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'view', + ); + + const viewGroupRepository = + await this.twentyORMGlobalManager.getRepositoryForWorkspace( + workspaceId, + 'viewGroup', + ); + + const views = await viewRepository.find({ + relations: ['viewGroups'], + }); + + for (const view of views) { + if (view.viewGroups.length === 0) { + continue; + } + + // We're assuming for now that all viewGroups belonging to the same view have the same fieldMetadataId + const viewGroup = view.viewGroups?.[0]; + const fieldMetadataId = viewGroup?.fieldMetadataId; + + if (!fieldMetadataId || !viewGroup) { + continue; + } + + const fieldMetadata = await this.fieldMetadataRepository.findOne({ + where: { id: viewGroup.fieldMetadataId }, + }); + + if (!fieldMetadata) { + continue; + } + + await this.fieldMetadataRelatedRecordsService.syncNoValueViewGroup( + fieldMetadata, + view, + viewGroupRepository, + ); + } + } + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts index bf4c1978b1..736d557c6f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.module.ts @@ -84,8 +84,9 @@ import { UpdateFieldInput } from './dtos/update-field.input'; IsFieldMetadataDefaultValue, IsFieldMetadataOptions, FieldMetadataService, + FieldMetadataRelatedRecordsService, FieldMetadataResolver, ], - exports: [FieldMetadataService], + exports: [FieldMetadataService, FieldMetadataRelatedRecordsService], }) export class FieldMetadataModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service.ts index 61757b18eb..cbe1822e3d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/services/field-metadata-related-records.service.ts @@ -8,6 +8,7 @@ import { } from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { isSelectFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-select-field-metadata-type.util'; +import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager'; import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity'; import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity'; @@ -28,7 +29,7 @@ export class FieldMetadataRelatedRecordsService { oldFieldMetadata: FieldMetadataEntity, newFieldMetadata: FieldMetadataEntity, transactionManager?: EntityManager, - ) { + ): Promise { if ( !isSelectFieldMetadataType(newFieldMetadata.type) || !isSelectFieldMetadataType(oldFieldMetadata.type) @@ -54,10 +55,7 @@ export class FieldMetadataRelatedRecordsService { continue; } - const maxPosition = view.viewGroups.reduce( - (max, viewGroup) => Math.max(max, viewGroup.position), - 0, - ); + const maxPosition = this.getMaxPosition(view.viewGroups); const viewGroupsToCreate = created.map((option, index) => viewGroupRepository.create({ @@ -72,21 +70,19 @@ export class FieldMetadataRelatedRecordsService { await viewGroupRepository.insert(viewGroupsToCreate, transactionManager); for (const { old: oldOption, new: newOption } of updated) { - const viewGroup = view.viewGroups.find( - (viewGroup) => viewGroup.fieldValue === oldOption.value, + const existingViewGroup = view.viewGroups.find( + (group) => group.fieldValue === oldOption.value, ); - if (!viewGroup) { - throw new Error(`View group not found for option ${oldOption.value}`); + if (!existingViewGroup) { + throw new Error( + `View group not found for option "${oldOption.value}" during update.`, + ); } await viewGroupRepository.update( - { - id: viewGroup.id, - }, - { - fieldValue: newOption.value, - }, + { id: existingViewGroup.id }, + { fieldValue: newOption.value }, transactionManager, ); } @@ -100,13 +96,49 @@ export class FieldMetadataRelatedRecordsService { }, transactionManager, ); + + await this.syncNoValueViewGroup( + newFieldMetadata, + view, + viewGroupRepository, + transactionManager, + ); + } + } + + async syncNoValueViewGroup( + fieldMetadata: FieldMetadataEntity, + view: ViewWorkspaceEntity, + viewGroupRepository: WorkspaceRepository, + transactionManager?: EntityManager, + ): Promise { + const noValueGroup = view.viewGroups.find( + (group) => group.fieldValue === '', + ); + + if (fieldMetadata.isNullable && !noValueGroup) { + const maxPosition = this.getMaxPosition(view.viewGroups); + const newGroup = viewGroupRepository.create({ + fieldMetadataId: fieldMetadata.id, + fieldValue: '', + position: maxPosition + 1, + isVisible: true, + viewId: view.id, + }); + + await viewGroupRepository.insert(newGroup, transactionManager); + } else if (!fieldMetadata.isNullable && noValueGroup) { + await viewGroupRepository.delete( + { id: noValueGroup.id }, + transactionManager, + ); } } private getOptionsDifferences( oldOptions: (FieldMetadataDefaultOption | FieldMetadataComplexOption)[], newOptions: (FieldMetadataDefaultOption | FieldMetadataComplexOption)[], - ) { + ): Differences { const differences: Differences< FieldMetadataDefaultOption | FieldMetadataComplexOption > = { @@ -115,12 +147,8 @@ export class FieldMetadataRelatedRecordsService { deleted: [], }; - const oldOptionsMap = new Map( - oldOptions.map((option) => [option.id, option]), - ); - const newOptionsMap = new Map( - newOptions.map((option) => [option.id, option]), - ); + const oldOptionsMap = new Map(oldOptions.map((opt) => [opt.id, opt])); + const newOptionsMap = new Map(newOptions.map((opt) => [opt.id, opt])); for (const newOption of newOptions) { const oldOption = oldOptionsMap.get(newOption.id); @@ -150,7 +178,7 @@ export class FieldMetadataRelatedRecordsService { 'view', ); - return await viewRepository.find({ + return viewRepository.find({ where: { viewGroups: { fieldMetadataId: fieldMetadata.id, @@ -159,4 +187,8 @@ export class FieldMetadataRelatedRecordsService { relations: ['viewGroups'], }); } + + private getMaxPosition(viewGroups: ViewGroupWorkspaceEntity[]): number { + return viewGroups.reduce((max, group) => Math.max(max, group.position), 0); + } }