diff --git a/packages/twenty-front/src/modules/navigation-menu-item/edit/hooks/useOptimisticRemoveNavigationMenuItemsByViewId.ts b/packages/twenty-front/src/modules/navigation-menu-item/edit/hooks/useOptimisticRemoveNavigationMenuItemsByViewId.ts new file mode 100644 index 0000000000..8f843d6778 --- /dev/null +++ b/packages/twenty-front/src/modules/navigation-menu-item/edit/hooks/useOptimisticRemoveNavigationMenuItemsByViewId.ts @@ -0,0 +1,35 @@ +import { useCallback } from 'react'; + +import { useMetadataStore } from '@/metadata-store/hooks/useMetadataStore'; +import { metadataStoreState } from '@/metadata-store/states/metadataStoreState'; +import { type NavigationMenuItem } from '~/generated-metadata/graphql'; +import { isDefined } from 'twenty-shared/utils'; +import { useStore } from 'jotai'; + +export const useOptimisticRemoveNavigationMenuItemsByViewId = () => { + const store = useStore(); + const { replaceDraft, applyChanges } = useMetadataStore(); + + const removeNavigationMenuItemsByViewIds = useCallback( + (viewIds: string[]) => { + const viewIdsSet = new Set(viewIds); + const entry = store.get( + metadataStoreState.atomFamily('navigationMenuItems'), + ); + const currentNavigationMenuItems = + entry.current as unknown as NavigationMenuItem[]; + + const updatedNavigationMenuItems = currentNavigationMenuItems.filter( + (item) => !isDefined(item.viewId) || !viewIdsSet.has(item.viewId), + ); + + replaceDraft('navigationMenuItems', updatedNavigationMenuItems); + applyChanges(); + }, + [store, replaceDraft, applyChanges], + ); + + return { + removeNavigationMenuItemsByViewIds, + }; +}; diff --git a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerOptionDropdown.tsx b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerOptionDropdown.tsx index 961d12a4d6..9de0300bbb 100644 --- a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerOptionDropdown.tsx +++ b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerOptionDropdown.tsx @@ -1,5 +1,6 @@ import { useCreateNavigationMenuItem } from '@/navigation-menu-item/common/hooks/useCreateNavigationMenuItem'; import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData'; +import { useSortedNavigationMenuItems } from '@/navigation-menu-item/display/hooks/useSortedNavigationMenuItems'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { useHasPermissionFlag } from '@/settings/roles/hooks/useHasPermissionFlag'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; @@ -54,15 +55,15 @@ export const ViewPickerOptionDropdown = ({ const hasViewsPermission = useHasPermissionFlag(PermissionFlagType.VIEWS); const { createNavigationMenuItem } = useCreateNavigationMenuItem(); - const { navigationMenuItems, currentWorkspaceMemberId } = - useNavigationMenuItemsData(); + const { currentWorkspaceMemberId } = useNavigationMenuItemsData(); + const { navigationMenuItemsSorted } = useSortedNavigationMenuItems(); // Users with VIEWS permission can edit all views // Users without VIEWS permission can only edit unlisted views (which are always their own, filtered by backend) const canEditView = hasViewsPermission || view.visibility === ViewVisibility.UNLISTED; - const isFavorite = navigationMenuItems.some( + const isFavorite = navigationMenuItemsSorted.some( (item) => item.viewId === view.id && item.userWorkspaceId === currentWorkspaceMemberId, diff --git a/packages/twenty-front/src/modules/views/view-picker/hooks/useDestroyViewFromCurrentState.ts b/packages/twenty-front/src/modules/views/view-picker/hooks/useDestroyViewFromCurrentState.ts index 997bc474a7..4bdde840f0 100644 --- a/packages/twenty-front/src/modules/views/view-picker/hooks/useDestroyViewFromCurrentState.ts +++ b/packages/twenty-front/src/modules/views/view-picker/hooks/useDestroyViewFromCurrentState.ts @@ -2,6 +2,7 @@ import { useCallback } from 'react'; import { useStore } from 'jotai'; import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow'; +import { useOptimisticRemoveNavigationMenuItemsByViewId } from '@/navigation-menu-item/edit/hooks/useOptimisticRemoveNavigationMenuItemsByViewId'; import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue'; import { usePerformViewAPIPersist } from '@/views/hooks/internal/usePerformViewAPIPersist'; @@ -45,6 +46,8 @@ export const useDestroyViewFromCurrentState = (viewBarInstanceId?: string) => { const { changeView } = useChangeView(); const { performViewAPIDestroy } = usePerformViewAPIPersist(); + const { removeNavigationMenuItemsByViewIds } = + useOptimisticRemoveNavigationMenuItemsByViewId(); const store = useStore(); @@ -72,11 +75,13 @@ export const useDestroyViewFromCurrentState = (viewBarInstanceId?: string) => { } await performViewAPIDestroy({ id: viewPickerReferenceViewId }); + removeNavigationMenuItemsByViewIds([viewPickerReferenceViewId]); }, [ currentView, closeAndResetViewPicker, changeView, performViewAPIDestroy, + removeNavigationMenuItemsByViewIds, store, viewPickerIsDirtyCallbackState, viewPickerIsPersistingCallbackState, diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-delete-orphan-navigation-menu-items.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-delete-orphan-navigation-menu-items.command.ts new file mode 100644 index 0000000000..97dd250934 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-delete-orphan-navigation-menu-items.command.ts @@ -0,0 +1,91 @@ +import { Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { In, Repository } from 'typeorm'; +import { isDefined } from 'twenty-shared/utils'; +import { NavigationMenuItemType } from 'twenty-shared/types'; + +import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { NavigationMenuItemEntity } from 'src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; + +@Command({ + name: 'upgrade:1-20:delete-orphan-navigation-menu-items', + description: 'Delete navigation menu items pointing to deleted views', +}) +export class DeleteOrphanNavigationMenuItemsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + protected readonly logger = new Logger( + DeleteOrphanNavigationMenuItemsCommand.name, + ); + + constructor( + @InjectRepository(WorkspaceEntity) + protected readonly workspaceRepository: Repository, + protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager, + protected readonly dataSourceService: DataSourceService, + @InjectRepository(NavigationMenuItemEntity) + private readonly navigationMenuItemRepository: Repository, + private readonly workspaceCacheService: WorkspaceCacheService, + ) { + super(workspaceRepository, twentyORMGlobalManager, dataSourceService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const { flatViewMaps, flatNavigationMenuItemMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatViewMaps', + 'flatNavigationMenuItemMaps', + ]); + + const activeViewIds = new Set( + Object.values(flatViewMaps.byUniversalIdentifier) + .filter((view): view is NonNullable => isDefined(view)) + .filter((view) => view.deletedAt === null) + .map((view) => view.id), + ); + + const orphanViewNavigationMenuItemIds = Object.values( + flatNavigationMenuItemMaps.byUniversalIdentifier, + ) + .filter( + (item): item is NonNullable => + isDefined(item) && + item.type === NavigationMenuItemType.VIEW && + isDefined(item.viewId) && + !activeViewIds.has(item.viewId), + ) + .map((item) => item.id); + + if (orphanViewNavigationMenuItemIds.length === 0) { + return; + } + + if (options.dryRun) { + this.logger.log( + `[DRY RUN] Would delete ${orphanViewNavigationMenuItemIds.length} orphan navigation menu item(s) for workspace ${workspaceId}`, + ); + return; + } + + await this.navigationMenuItemRepository.delete({ + workspaceId, + id: In(orphanViewNavigationMenuItemIds), + }); + + this.logger.log( + `Deleted ${orphanViewNavigationMenuItemIds.length} orphan navigation menu item(s) for workspace ${workspaceId}`, + ); + + await this.workspaceCacheService.flush(workspaceId, [ + 'flatNavigationMenuItemMaps', + ]); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-upgrade-version-command.module.ts index 6cacd6d11c..5c1d7d265a 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-upgrade-version-command.module.ts @@ -5,6 +5,7 @@ import { BackfillCommandMenuItemsCommand } from 'src/database/commands/upgrade-v import { BackfillNavigationMenuItemTypeCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-navigation-menu-item-type.command'; import { BackfillPageLayoutsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-page-layouts.command'; import { BackfillSelectFieldOptionIdsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-select-field-option-ids.command'; +import { DeleteOrphanNavigationMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-delete-orphan-navigation-menu-items.command'; import { IdentifyObjectPermissionMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-object-permission-metadata.command'; import { IdentifyPermissionFlagMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-permission-flag-metadata.command'; import { MakeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-object-permission-universal-identifier-and-application-id-not-nullable-migration.command'; @@ -22,6 +23,7 @@ import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-ac import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity'; import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity'; +import { NavigationMenuItemEntity } from 'src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity'; import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; @@ -37,6 +39,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace CalendarChannelEntity, MessageFolderEntity, UserWorkspaceEntity, + NavigationMenuItemEntity, ]), DataSourceModule, WorkspaceCacheModule, @@ -57,6 +60,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace BackfillNavigationMenuItemTypeCommand, BackfillPageLayoutsCommand, BackfillSelectFieldOptionIdsCommand, + DeleteOrphanNavigationMenuItemsCommand, SeedCliApplicationRegistrationCommand, MigrateRichTextToTextCommand, MigrateMessagingInfrastructureToMetadataCommand, @@ -70,6 +74,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace BackfillNavigationMenuItemTypeCommand, BackfillPageLayoutsCommand, BackfillSelectFieldOptionIdsCommand, + DeleteOrphanNavigationMenuItemsCommand, SeedCliApplicationRegistrationCommand, MigrateRichTextToTextCommand, MigrateMessagingInfrastructureToMetadataCommand, diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts index 65afacbc20..6f8121836f 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts @@ -35,6 +35,7 @@ import { FixInvalidStandardUniversalIdentifiersCommand } from 'src/database/comm import { SeedServerIdCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-seed-server-id.command'; import { BackfillCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command'; import { BackfillNavigationMenuItemTypeCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-navigation-menu-item-type.command'; +import { DeleteOrphanNavigationMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-delete-orphan-navigation-menu-items.command'; import { BackfillPageLayoutsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-page-layouts.command'; import { BackfillSelectFieldOptionIdsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-select-field-option-ids.command'; import { IdentifyObjectPermissionMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-object-permission-metadata.command'; @@ -100,6 +101,7 @@ export class UpgradeCommand extends UpgradeCommandRunner { protected readonly makeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand: MakeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand, protected readonly backfillNavigationMenuItemTypeCommand: BackfillNavigationMenuItemTypeCommand, protected readonly backfillCommandMenuItemsCommand: BackfillCommandMenuItemsCommand, + protected readonly deleteOrphanNavigationMenuItemsCommand: DeleteOrphanNavigationMenuItemsCommand, protected readonly backfillPageLayoutsCommand: BackfillPageLayoutsCommand, protected readonly seedCliApplicationRegistrationCommand: SeedCliApplicationRegistrationCommand, protected readonly migrateRichTextToTextCommand: MigrateRichTextToTextCommand, @@ -160,6 +162,7 @@ export class UpgradeCommand extends UpgradeCommandRunner { .makeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand, this.backfillNavigationMenuItemTypeCommand, this.migrateRichTextToTextCommand, + this.deleteOrphanNavigationMenuItemsCommand, this.backfillCommandMenuItemsCommand, this.backfillPageLayoutsCommand, this.seedCliApplicationRegistrationCommand, diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-deletion.service.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-deletion.service.ts index 8ef7ce4e48..8fed8cd2c7 100644 --- a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-deletion.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-deletion.service.ts @@ -4,10 +4,24 @@ import { isDefined } from 'twenty-shared/utils'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type'; import { fromDeleteNavigationMenuItemInputToFlatNavigationMenuItemOrThrow } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/from-delete-navigation-menu-item-input-to-flat-navigation-menu-item-or-throw.util'; import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; +const isNavigationMenuItemForDeletedRecord = ( + item: FlatNavigationMenuItem, + deletedIdsSet: Set, +): boolean => + isDefined(item.targetRecordId) && + !isDefined(item.viewId) && + deletedIdsSet.has(item.targetRecordId); + +const isNavigationMenuItemForDeletedView = ( + item: FlatNavigationMenuItem, + deletedIdsSet: Set, +): boolean => isDefined(item.viewId) && deletedIdsSet.has(item.viewId); + @Injectable() export class NavigationMenuItemDeletionService { constructor( @@ -40,9 +54,8 @@ export class NavigationMenuItemDeletionService { ).filter( (item): item is NonNullable => isDefined(item) && - isDefined(item.targetRecordId) && - !isDefined(item.viewId) && - deletedRecordIdsSet.has(item.targetRecordId), + (isNavigationMenuItemForDeletedRecord(item, deletedRecordIdsSet) || + isNavigationMenuItemForDeletedView(item, deletedRecordIdsSet)), ); if (navigationMenuItemsToDelete.length === 0) { diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/services/dev-seeder-metadata.service.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/services/dev-seeder-metadata.service.ts index 03f980c611..23c212a6a2 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/services/dev-seeder-metadata.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/metadata/services/dev-seeder-metadata.service.ts @@ -175,7 +175,7 @@ export class DevSeederMetadataService { }, }; - private getLightConfig(config: WorkspaceSeedConfig): WorkspaceSeedConfig { + private getLightConfig(_config: WorkspaceSeedConfig): WorkspaceSeedConfig { return { objects: [], fields: [], diff --git a/packages/twenty-server/src/modules/messaging/common/services/message-channel-sync-status.service.ts b/packages/twenty-server/src/modules/messaging/common/services/message-channel-sync-status.service.ts index 49f8459baa..47e882149a 100644 --- a/packages/twenty-server/src/modules/messaging/common/services/message-channel-sync-status.service.ts +++ b/packages/twenty-server/src/modules/messaging/common/services/message-channel-sync-status.service.ts @@ -18,7 +18,6 @@ import { MessageChannelPendingGroupEmailsAction, MessageChannelSyncStage, MessageChannelSyncStatus, - type MessageChannelWorkspaceEntity, } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity'; import { MessageFolderPendingSyncAction } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity'; import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';