From 097432d3a248a131dc5daf2aae4de79cc1d39dbb Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Fri, 24 Apr 2026 15:48:02 +0530 Subject: [PATCH] [Command Menu] Refactor layout customization conditional availability [Warning] (#19974) closes https://discord.com/channels/1130383047699738754/1494312529286004837 --- .../useExecuteTasksOnAnyLocationChange.ts | 9 - .../components/StandalonePageCommandMenu.tsx | 14 +- .../constants/EmptyCommandMenuContextApi.ts | 3 +- .../__tests__/useCloseCommandMenu.test.tsx | 3 +- .../hooks/useCommandMenuContextApi.ts | 24 ++- ...textStoreIsPageInEditModeComponentState.ts | 9 - .../hooks/useEnterLayoutCustomizationMode.ts | 27 ++- .../hooks/useSetIsPageLayoutInEditMode.ts | 11 -- .../hooks/useOpenRecordInSidePanel.ts | 12 -- ...nditional-availability-expressions.test.ts | 3 +- .../conditional-availability-variables.ts | 4 + .../src/sdk/front-component/index.ts | 2 + .../2-1/2-1-upgrade-version-command.module.ts | 15 +- ...mization-guard-to-edit-commands.command.ts | 180 ++++++++++++++++++ ...acy-page-edit-mode-identifier.util.spec.ts | 63 ++++++ ...e-legacy-page-edit-mode-identifier.util.ts | 39 ++++ .../standard-command-menu-item.constant.ts | 8 +- .../src/types/CommandMenuContextApi.ts | 3 +- ...eConditionalAvailabilityExpression.test.ts | 3 +- ...interpolateCommandMenuItemTemplate.test.ts | 3 +- 20 files changed, 374 insertions(+), 61 deletions(-) delete mode 100644 packages/twenty-front/src/modules/context-store/states/contextStoreIsPageInEditModeComponentState.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-1/2-1-workspace-command-1795000001000-add-layout-customization-guard-to-edit-commands.command.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-1/utils/__tests__/replace-legacy-page-edit-mode-identifier.util.spec.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-1/utils/replace-legacy-page-edit-mode-identifier.util.ts diff --git a/packages/twenty-front/src/modules/app/hooks/useExecuteTasksOnAnyLocationChange.ts b/packages/twenty-front/src/modules/app/hooks/useExecuteTasksOnAnyLocationChange.ts index 2ddb570cf2..983a555c55 100644 --- a/packages/twenty-front/src/modules/app/hooks/useExecuteTasksOnAnyLocationChange.ts +++ b/packages/twenty-front/src/modules/app/hooks/useExecuteTasksOnAnyLocationChange.ts @@ -1,5 +1,3 @@ -import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId'; -import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState'; import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState'; import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState'; import { fieldsWidgetEditorModeDraftComponentState } from '@/page-layout/states/fieldsWidgetEditorModeDraftComponentState'; @@ -130,13 +128,6 @@ export const useExecuteTasksOnAnyLocationChange = () => { store.set(currentPageLayoutIdState.atom, null); } - - store.set( - contextStoreIsPageInEditModeComponentState.atomFamily({ - instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID, - }), - false, - ); }, [store]); /** diff --git a/packages/twenty-front/src/modules/command-menu-item/components/StandalonePageCommandMenu.tsx b/packages/twenty-front/src/modules/command-menu-item/components/StandalonePageCommandMenu.tsx index 6fbb137696..b0c5199a32 100644 --- a/packages/twenty-front/src/modules/command-menu-item/components/StandalonePageCommandMenu.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/components/StandalonePageCommandMenu.tsx @@ -6,6 +6,7 @@ import { CommandMenuItemEditButton } from '@/command-menu-item/edit/components/C import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector'; import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId'; import { doesCommandMenuItemMatchPageLayoutId } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageLayoutId'; +import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; @@ -25,6 +26,9 @@ export const StandalonePageCommandMenu = () => { const commandMenuItems = useAtomStateValue(commandMenuItemsSelector); const currentWorkspace = useAtomStateValue(currentWorkspaceState); const currentPageLayoutId = useAtomStateValue(currentPageLayoutIdState); + const isLayoutCustomizationModeEnabled = useAtomStateValue( + isLayoutCustomizationModeEnabledState, + ); const { objectMetadataItems } = useObjectMetadataItems(); const commandMenuContextApi = useMemo(() => { @@ -52,7 +56,8 @@ export const StandalonePageCommandMenu = () => { return { pageType: ContextStorePageType.Standalone, isInSidePanel: false, - isPageInEditMode: false, + isDashboardPageLayoutInEditMode: false, + isLayoutCustomizationModeEnabled, favoriteRecordIds: [], isSelectAll: false, hasAnySoftDeleteFilterOnView: false, @@ -74,7 +79,12 @@ export const StandalonePageCommandMenu = () => { objectMetadataItem: {}, objectMetadataLabel: '', }; - }, [currentWorkspace?.featureFlags, objectMetadataItems, store]); + }, [ + currentWorkspace?.featureFlags, + isLayoutCustomizationModeEnabled, + objectMetadataItems, + store, + ]); const filteredCommandMenuItems = useMemo(() => { return commandMenuItems diff --git a/packages/twenty-front/src/modules/command-menu-item/constants/EmptyCommandMenuContextApi.ts b/packages/twenty-front/src/modules/command-menu-item/constants/EmptyCommandMenuContextApi.ts index 64432d01fe..e3f93695cc 100644 --- a/packages/twenty-front/src/modules/command-menu-item/constants/EmptyCommandMenuContextApi.ts +++ b/packages/twenty-front/src/modules/command-menu-item/constants/EmptyCommandMenuContextApi.ts @@ -6,7 +6,8 @@ import { export const EMPTY_COMMAND_MENU_CONTEXT_API: CommandMenuContextApi = { pageType: ContextStorePageType.Index, isInSidePanel: false, - isPageInEditMode: false, + isDashboardPageLayoutInEditMode: false, + isLayoutCustomizationModeEnabled: false, favoriteRecordIds: [], isSelectAll: false, hasAnySoftDeleteFilterOnView: false, diff --git a/packages/twenty-front/src/modules/command-menu-item/hooks/__tests__/useCloseCommandMenu.test.tsx b/packages/twenty-front/src/modules/command-menu-item/hooks/__tests__/useCloseCommandMenu.test.tsx index 01da14f6f0..097d95b80a 100644 --- a/packages/twenty-front/src/modules/command-menu-item/hooks/__tests__/useCloseCommandMenu.test.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/hooks/__tests__/useCloseCommandMenu.test.tsx @@ -46,7 +46,8 @@ const getWrapper = commandMenuContextApi: { pageType: ContextStorePageType.Index, isInSidePanel, - isPageInEditMode: false, + isDashboardPageLayoutInEditMode: false, + isLayoutCustomizationModeEnabled: false, favoriteRecordIds: [], isSelectAll: false, hasAnySoftDeleteFilterOnView: false, diff --git a/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuContextApi.ts b/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuContextApi.ts index bc9b8e2e72..b4da0221ff 100644 --- a/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuContextApi.ts +++ b/packages/twenty-front/src/modules/command-menu-item/hooks/useCommandMenuContextApi.ts @@ -4,15 +4,17 @@ import { ContextStoreComponentInstanceContext } from '@/context-store/states/con import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; import { contextStoreCurrentPageTypeComponentState } from '@/context-store/states/contextStoreCurrentPageTypeComponentState'; -import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState'; import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { useNavigationMenuItemsData } from '@/navigation-menu-item/display/hooks/useNavigationMenuItemsData'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPermissionsForObject'; +import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState'; import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView'; import { recordStoreRecordsSelector } from '@/object-record/record-store/states/selectors/recordStoreRecordsSelector'; import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId'; +import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState'; +import { isDashboardInEditModeComponentState } from '@/page-layout/states/isDashboardInEditModeComponentState'; import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; @@ -20,7 +22,7 @@ import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/use import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { isNonEmptyArray } from '@sniptt/guards'; -import { useStore } from 'jotai'; +import { useAtomValue, useStore } from 'jotai'; import { ContextStorePageType, type CommandMenuContextApi, @@ -77,6 +79,11 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => { { recordIds: recordIds ?? [] }, ); + const currentPageLayoutId = useAtomStateValue(currentPageLayoutIdState); + + const dashboardPageLayoutIdForCommandMenu = + selectedRecords[0]?.pageLayoutId ?? currentPageLayoutId ?? ''; + const objectPermissionsFromHook = useObjectPermissionsForObject( objectMetadataItem?.id ?? '', ); @@ -111,8 +118,14 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => { contextStoreCurrentPageTypeComponentState, ); - const contextStoreIsPageInEditMode = useAtomComponentStateValue( - contextStoreIsPageInEditModeComponentState, + const isDashboardInEditMode = useAtomValue( + isDashboardInEditModeComponentState.atomFamily({ + instanceId: dashboardPageLayoutIdForCommandMenu, + }), + ); + + const isLayoutCustomizationModeEnabled = useAtomStateValue( + isLayoutCustomizationModeEnabledState, ); const pageType = isDefined(contextStoreCurrentPageType) @@ -154,7 +167,8 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => { return { pageType, isInSidePanel, - isPageInEditMode: contextStoreIsPageInEditMode, + isDashboardPageLayoutInEditMode: isDashboardInEditMode, + isLayoutCustomizationModeEnabled, favoriteRecordIds, isSelectAll, hasAnySoftDeleteFilterOnView, diff --git a/packages/twenty-front/src/modules/context-store/states/contextStoreIsPageInEditModeComponentState.ts b/packages/twenty-front/src/modules/context-store/states/contextStoreIsPageInEditModeComponentState.ts deleted file mode 100644 index d6aba1f576..0000000000 --- a/packages/twenty-front/src/modules/context-store/states/contextStoreIsPageInEditModeComponentState.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; -import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; - -export const contextStoreIsPageInEditModeComponentState = - createAtomComponentState({ - key: 'contextStoreIsPageInEditModeComponentState', - defaultValue: false, - componentInstanceContext: ContextStoreComponentInstanceContext, - }); diff --git a/packages/twenty-front/src/modules/layout-customization/hooks/useEnterLayoutCustomizationMode.ts b/packages/twenty-front/src/modules/layout-customization/hooks/useEnterLayoutCustomizationMode.ts index c133e47112..cbaaae0ce7 100644 --- a/packages/twenty-front/src/modules/layout-customization/hooks/useEnterLayoutCustomizationMode.ts +++ b/packages/twenty-front/src/modules/layout-customization/hooks/useEnterLayoutCustomizationMode.ts @@ -2,6 +2,7 @@ import { t } from '@lingui/core/macro'; import { useStore } from 'jotai'; import { useCallback } from 'react'; import { SidePanelPages } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; import { IconPencil } from 'twenty-ui/display'; import { commandMenuItemsDraftState } from '@/command-menu-item/edit/states/commandMenuItemsDraftState'; @@ -11,13 +12,17 @@ import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/st import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState'; import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/states/navigationMenuItemsSelector'; import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/common/utils/filterWorkspaceNavigationMenuItems'; +import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState'; +import { isDashboardInEditModeComponentState } from '@/page-layout/states/isDashboardInEditModeComponentState'; import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel'; import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState'; import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; export const useEnterLayoutCustomizationMode = () => { const store = useStore(); const { navigateSidePanel } = useNavigateSidePanel(); + const { enqueueWarningSnackBar } = useSnackBar(); const enterLayoutCustomizationMode = useCallback(() => { const isLayoutCustomizationModeAlreadyEnabled = store.get( @@ -28,6 +33,26 @@ export const useEnterLayoutCustomizationMode = () => { return; } + const dashboardPageLayoutIdInEditMode = store.get( + currentPageLayoutIdState.atom, + ); + + if (isDefined(dashboardPageLayoutIdInEditMode)) { + const isDashboardInEditMode = store.get( + isDashboardInEditModeComponentState.atomFamily({ + instanceId: dashboardPageLayoutIdInEditMode, + }), + ); + + if (isDashboardInEditMode) { + enqueueWarningSnackBar({ + message: t`Save or cancel dashboard changes before editing the layout.`, + }); + + return; + } + } + const prefetchNavigationMenuItems = store.get( navigationMenuItemsSelector.atom, ); @@ -57,7 +82,7 @@ export const useEnterLayoutCustomizationMode = () => { resetNavigationStack: true, }); } - }, [navigateSidePanel, store]); + }, [enqueueWarningSnackBar, navigateSidePanel, store]); return { enterLayoutCustomizationMode }; }; diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useSetIsPageLayoutInEditMode.ts b/packages/twenty-front/src/modules/page-layout/hooks/useSetIsPageLayoutInEditMode.ts index d013f65e15..2916e6eaf8 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useSetIsPageLayoutInEditMode.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useSetIsPageLayoutInEditMode.ts @@ -1,5 +1,3 @@ -import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId'; -import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState'; import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState'; import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext'; import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState'; @@ -27,12 +25,6 @@ export const useSetIsPageLayoutInEditMode = (pageLayoutIdFromProps: string) => { pageLayoutId, ); - const contextStoreIsFullTabWidgetInEditModeState = - useAtomComponentStateCallbackState( - contextStoreIsPageInEditModeComponentState, - MAIN_CONTEXT_STORE_INSTANCE_ID, - ); - const fieldsWidgetGroupsDraftState = useAtomComponentStateCallbackState( fieldsWidgetGroupsDraftComponentState, pageLayoutId, @@ -93,15 +85,12 @@ export const useSetIsPageLayoutInEditMode = (pageLayoutIdFromProps: string) => { store.set(isDashboardInEditModeState, value); - store.set(contextStoreIsFullTabWidgetInEditModeState, value); - if (value) { store.set(currentPageLayoutIdState.atom, pageLayoutId); } }, [ isDashboardInEditModeState, - contextStoreIsFullTabWidgetInEditModeState, fieldsWidgetGroupsDraftState, fieldsWidgetUngroupedFieldsDraftState, fieldsWidgetEditorModeDraftState, diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useOpenRecordInSidePanel.ts b/packages/twenty-front/src/modules/side-panel/hooks/useOpenRecordInSidePanel.ts index fa6e51cde0..c310752670 100644 --- a/packages/twenty-front/src/modules/side-panel/hooks/useOpenRecordInSidePanel.ts +++ b/packages/twenty-front/src/modules/side-panel/hooks/useOpenRecordInSidePanel.ts @@ -7,7 +7,6 @@ import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainCo import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; import { contextStoreCurrentPageTypeComponentState } from '@/context-store/states/contextStoreCurrentPageTypeComponentState'; -import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState'; import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector'; @@ -137,17 +136,6 @@ export const useOpenRecordInSidePanel = () => { ), ); - store.set( - contextStoreIsPageInEditModeComponentState.atomFamily({ - instanceId: pageComponentInstanceId, - }), - store.get( - contextStoreIsPageInEditModeComponentState.atomFamily({ - instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID, - }), - ), - ); - const currentMorphItems = store.get( sidePanelNavigationMorphItemsByPageState.atom, ); diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/conditional-availability/__tests__/transform-conditional-availability-expressions.test.ts b/packages/twenty-sdk/src/cli/utilities/build/common/conditional-availability/__tests__/transform-conditional-availability-expressions.test.ts index 7b8c14b3f2..bfc4765f4e 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/conditional-availability/__tests__/transform-conditional-availability-expressions.test.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/conditional-availability/__tests__/transform-conditional-availability-expressions.test.ts @@ -18,7 +18,8 @@ const buildMockCommandMenuContextApi = ( ): CommandMenuContextApi => ({ pageType: ContextStorePageType.Index, isInSidePanel: false, - isPageInEditMode: false, + isDashboardPageLayoutInEditMode: false, + isLayoutCustomizationModeEnabled: false, favoriteRecordIds: [], isSelectAll: false, hasAnySoftDeleteFilterOnView: false, diff --git a/packages/twenty-sdk/src/sdk/front-component/conditional-availability/conditional-availability-variables.ts b/packages/twenty-sdk/src/sdk/front-component/conditional-availability/conditional-availability-variables.ts index 14f61237b8..55127adb06 100644 --- a/packages/twenty-sdk/src/sdk/front-component/conditional-availability/conditional-availability-variables.ts +++ b/packages/twenty-sdk/src/sdk/front-component/conditional-availability/conditional-availability-variables.ts @@ -3,6 +3,10 @@ import { type CommandMenuContextApi } from 'twenty-shared/types'; export const pageType = null as unknown as CommandMenuContextApi['pageType']; export const isInSidePanel = null as unknown as CommandMenuContextApi['isInSidePanel']; +export const isDashboardPageLayoutInEditMode = + null as unknown as CommandMenuContextApi['isDashboardPageLayoutInEditMode']; +export const isLayoutCustomizationModeEnabled = + null as unknown as CommandMenuContextApi['isLayoutCustomizationModeEnabled']; export const favoriteRecordIds = null as unknown as CommandMenuContextApi['favoriteRecordIds']; export const isSelectAll = diff --git a/packages/twenty-sdk/src/sdk/front-component/index.ts b/packages/twenty-sdk/src/sdk/front-component/index.ts index 7ea5c2016d..7df4f38355 100644 --- a/packages/twenty-sdk/src/sdk/front-component/index.ts +++ b/packages/twenty-sdk/src/sdk/front-component/index.ts @@ -1,6 +1,8 @@ export { pageType, isInSidePanel, + isDashboardPageLayoutInEditMode, + isLayoutCustomizationModeEnabled, favoriteRecordIds, isSelectAll, hasAnySoftDeleteFilterOnView, diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/2-1-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/2-1-upgrade-version-command.module.ts index 12edc78c40..847fd44a1a 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/2-1-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/2-1-upgrade-version-command.module.ts @@ -1,7 +1,18 @@ import { Module } from '@nestjs/common'; +import { AddLayoutCustomizationGuardToEditCommandsCommand } from 'src/database/commands/upgrade-version-command/2-1/2-1-workspace-command-1795000001000-add-layout-customization-guard-to-edit-commands.command'; +import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; +import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; +import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module'; + @Module({ - imports: [], - providers: [], + imports: [ + ApplicationModule, + WorkspaceCacheModule, + WorkspaceIteratorModule, + WorkspaceMigrationModule, + ], + providers: [AddLayoutCustomizationGuardToEditCommandsCommand], }) export class V2_1_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/2-1-workspace-command-1795000001000-add-layout-customization-guard-to-edit-commands.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/2-1-workspace-command-1795000001000-add-layout-customization-guard-to-edit-commands.command.ts new file mode 100644 index 0000000000..78a729462e --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/2-1-workspace-command-1795000001000-add-layout-customization-guard-to-edit-commands.command.ts @@ -0,0 +1,180 @@ +import { Command } from 'nest-commander'; +import { isDefined } from 'twenty-shared/utils'; + +import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; +import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; +import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { STANDARD_COMMAND_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant'; +import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant'; +import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; +import { replaceLegacyPageEditModeIdentifier } from 'src/database/commands/upgrade-version-command/2-1/utils/replace-legacy-page-edit-mode-identifier.util'; + +const UNIVERSAL_IDENTIFIERS_TO_UPDATE = new Set([ + STANDARD_COMMAND_MENU_ITEMS.editRecordPageLayout.universalIdentifier, + STANDARD_COMMAND_MENU_ITEMS.editDashboardLayout.universalIdentifier, + STANDARD_COMMAND_MENU_ITEMS.saveDashboardLayout.universalIdentifier, + STANDARD_COMMAND_MENU_ITEMS.cancelDashboardLayout.universalIdentifier, +]); + +@RegisteredWorkspaceCommand('2.1.0', 1795000001000) +@Command({ + name: 'upgrade:2-1:add-layout-customization-guard-to-edit-commands', + description: + 'Guard layout edit commands and migrate legacy page edit-mode expressions', +}) +export class AddLayoutCustomizationGuardToEditCommandsCommand extends ActiveOrSuspendedWorkspaceCommandRunner { + constructor( + protected readonly workspaceIteratorService: WorkspaceIteratorService, + private readonly applicationService: ApplicationService, + private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, + private readonly workspaceCacheService: WorkspaceCacheService, + ) { + super(workspaceIteratorService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Starting Edit command availability expression update for workspace ${workspaceId}`, + ); + + const { twentyStandardFlatApplication, workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatCommandMenuItemMaps', + ]); + + const { allFlatEntityMaps: standardAllFlatEntityMaps } = + computeTwentyStandardApplicationAllFlatEntityMaps({ + now: new Date().toISOString(), + workspaceId, + twentyStandardApplicationId: twentyStandardFlatApplication.id, + }); + + const updatedAt = new Date().toISOString(); + const itemsToUpdateById: Record = {}; + + for (const universalIdentifier of UNIVERSAL_IDENTIFIERS_TO_UPDATE) { + const standardItem = + standardAllFlatEntityMaps.flatCommandMenuItemMaps.byUniversalIdentifier[ + universalIdentifier + ]; + const existingItem = + existingFlatCommandMenuItemMaps.byUniversalIdentifier[ + universalIdentifier + ]; + + if ( + !isDefined(standardItem) || + !isDefined(existingItem) || + existingItem.conditionalAvailabilityExpression === + standardItem.conditionalAvailabilityExpression + ) { + continue; + } + + itemsToUpdateById[existingItem.id] = { + ...existingItem, + conditionalAvailabilityExpression: + standardItem.conditionalAvailabilityExpression, + updatedAt, + }; + } + + for (const existingItem of Object.values( + existingFlatCommandMenuItemMaps.byUniversalIdentifier, + ).filter(isDefined)) { + if ( + UNIVERSAL_IDENTIFIERS_TO_UPDATE.has(existingItem.universalIdentifier) + ) { + continue; + } + + const currentConditionalAvailabilityExpression = + existingItem.conditionalAvailabilityExpression; + + const nextConditionalAvailabilityExpression = + replaceLegacyPageEditModeIdentifier( + currentConditionalAvailabilityExpression, + ); + + if ( + nextConditionalAvailabilityExpression === + currentConditionalAvailabilityExpression + ) { + continue; + } + + itemsToUpdateById[existingItem.id] = { + ...existingItem, + conditionalAvailabilityExpression: + nextConditionalAvailabilityExpression, + updatedAt, + }; + } + + const itemsToUpdate = Object.values(itemsToUpdateById); + + if (itemsToUpdate.length === 0) { + this.logger.log( + `Edit command availability expressions already up to date for workspace ${workspaceId}`, + ); + + return; + } + + this.logger.log( + `Found ${itemsToUpdate.length} command menu item(s) to update for workspace ${workspaceId}`, + ); + + if (isDryRun) { + this.logger.log( + `[DRY RUN] Would update ${itemsToUpdate.length} Edit command availability expression(s) for workspace ${workspaceId}`, + ); + + return; + } + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + commandMenuItem: { + flatEntityToCreate: [], + flatEntityToDelete: [], + flatEntityToUpdate: itemsToUpdate, + }, + }, + workspaceId, + applicationUniversalIdentifier: + workspaceCustomFlatApplication.universalIdentifier, + }, + ); + + if (validateAndBuildResult.status === 'fail') { + this.logger.error( + `Failed to update Edit command availability expressions:\n${JSON.stringify(validateAndBuildResult, null, 2)}`, + ); + + throw new Error( + `Failed to update Edit command availability expressions for workspace ${workspaceId}`, + ); + } + + this.logger.log( + `Successfully updated ${itemsToUpdate.length} Edit command availability expression(s) for workspace ${workspaceId}`, + ); + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/utils/__tests__/replace-legacy-page-edit-mode-identifier.util.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/utils/__tests__/replace-legacy-page-edit-mode-identifier.util.spec.ts new file mode 100644 index 0000000000..5f3c30e362 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/utils/__tests__/replace-legacy-page-edit-mode-identifier.util.spec.ts @@ -0,0 +1,63 @@ +import { replaceLegacyPageEditModeIdentifier } from '../replace-legacy-page-edit-mode-identifier.util'; + +describe('replaceLegacyPageEditModeIdentifier', () => { + it('should replace the legacy edit-mode variable', () => { + expect( + replaceLegacyPageEditModeIdentifier( + 'pageType == "RECORD_PAGE" and not isPageInEditMode', + ), + ).toBe( + '((pageType == "RECORD_PAGE") and ((not isDashboardPageLayoutInEditMode)))', + ); + }); + + it('should replace the legacy variable in function arguments', () => { + expect( + replaceLegacyPageEditModeIdentifier( + 'someEquals(selectedRecords, "status", isPageInEditMode)', + ), + ).toBe( + 'someEquals(selectedRecords, "status", isDashboardPageLayoutInEditMode)', + ); + }); + + it('should replace every legacy variable occurrence', () => { + expect( + replaceLegacyPageEditModeIdentifier( + 'isPageInEditMode and not isPageInEditMode', + ), + ).toBe( + '(isDashboardPageLayoutInEditMode and ((not isDashboardPageLayoutInEditMode)))', + ); + }); + + it('should not replace string literals', () => { + const conditionalAvailabilityExpression = + 'objectMetadataItem.nameSingular == "isPageInEditMode"'; + + expect( + replaceLegacyPageEditModeIdentifier(conditionalAvailabilityExpression), + ).toBe(conditionalAvailabilityExpression); + }); + + it('should not replace member access', () => { + const conditionalAvailabilityExpression = + 'objectMetadataItem.isPageInEditMode'; + + expect( + replaceLegacyPageEditModeIdentifier(conditionalAvailabilityExpression), + ).toBe(conditionalAvailabilityExpression); + }); + + it('should leave invalid expressions untouched', () => { + const conditionalAvailabilityExpression = 'isPageInEditMode and ('; + + expect( + replaceLegacyPageEditModeIdentifier(conditionalAvailabilityExpression), + ).toBe(conditionalAvailabilityExpression); + }); + + it('should return null when expression is null', () => { + expect(replaceLegacyPageEditModeIdentifier(null)).toBeNull(); + }); +}); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/utils/replace-legacy-page-edit-mode-identifier.util.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/utils/replace-legacy-page-edit-mode-identifier.util.ts new file mode 100644 index 0000000000..6191cc23ca --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-1/utils/replace-legacy-page-edit-mode-identifier.util.ts @@ -0,0 +1,39 @@ +import { conditionalAvailabilityParser, isDefined } from 'twenty-shared/utils'; + +const LEGACY_PAGE_EDIT_MODE_IDENTIFIER = 'isPageInEditMode'; +const DASHBOARD_PAGE_LAYOUT_EDIT_MODE_IDENTIFIER = + 'isDashboardPageLayoutInEditMode'; +const DASHBOARD_PAGE_LAYOUT_EDIT_MODE_EXPRESSION = + conditionalAvailabilityParser.parse( + DASHBOARD_PAGE_LAYOUT_EDIT_MODE_IDENTIFIER, + ); + +export const replaceLegacyPageEditModeIdentifier = ( + conditionalAvailabilityExpression: string | null, +) => { + if (!isDefined(conditionalAvailabilityExpression)) { + return conditionalAvailabilityExpression; + } + + try { + const parsedConditionalAvailabilityExpression = + conditionalAvailabilityParser.parse(conditionalAvailabilityExpression); + + if ( + !parsedConditionalAvailabilityExpression + .variables() + .includes(LEGACY_PAGE_EDIT_MODE_IDENTIFIER) + ) { + return conditionalAvailabilityExpression; + } + + return parsedConditionalAvailabilityExpression + .substitute( + LEGACY_PAGE_EDIT_MODE_IDENTIFIER, + DASHBOARD_PAGE_LAYOUT_EDIT_MODE_EXPRESSION, + ) + .toString(); + } catch { + return conditionalAvailabilityExpression; + } +}; diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts index 01e2de2710..921a14591b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts @@ -263,7 +263,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { shortLabel: 'Edit Layout', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: - 'pageType == "RECORD_PAGE" and featureFlags.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED and noneDefined(selectedRecords, "deletedAt") and objectPermissions.canUpdateObjectRecords and objectMetadataItem.nameSingular != "dashboard"', + 'pageType == "RECORD_PAGE" and featureFlags.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED and not isLayoutCustomizationModeEnabled and noneDefined(selectedRecords, "deletedAt") and objectPermissions.canUpdateObjectRecords and objectMetadataItem.nameSingular != "dashboard"', availabilityObjectMetadataUniversalIdentifier: null, frontComponentUniversalIdentifier: null, engineComponentKey: EngineComponentKey.EDIT_RECORD_PAGE_LAYOUT, @@ -278,7 +278,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { shortLabel: 'Edit', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: - 'pageType == "RECORD_PAGE" and not isPageInEditMode and noneDefined(selectedRecords, "deletedAt") and everyDefined(selectedRecords, "pageLayoutId") and objectPermissions.canUpdateObjectRecords', + 'pageType == "RECORD_PAGE" and not isDashboardPageLayoutInEditMode and not isLayoutCustomizationModeEnabled and noneDefined(selectedRecords, "deletedAt") and everyDefined(selectedRecords, "pageLayoutId") and objectPermissions.canUpdateObjectRecords', availabilityObjectMetadataUniversalIdentifier: STANDARD_OBJECTS.dashboard.universalIdentifier, frontComponentUniversalIdentifier: null, @@ -294,7 +294,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { shortLabel: 'Save', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: - 'pageType == "RECORD_PAGE" and isPageInEditMode and noneDefined(selectedRecords, "deletedAt") and everyDefined(selectedRecords, "pageLayoutId") and objectPermissions.canUpdateObjectRecords', + 'pageType == "RECORD_PAGE" and isDashboardPageLayoutInEditMode and noneDefined(selectedRecords, "deletedAt") and everyDefined(selectedRecords, "pageLayoutId") and objectPermissions.canUpdateObjectRecords', availabilityObjectMetadataUniversalIdentifier: STANDARD_OBJECTS.dashboard.universalIdentifier, frontComponentUniversalIdentifier: null, @@ -310,7 +310,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { shortLabel: 'Cancel', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: - 'pageType == "RECORD_PAGE" and isPageInEditMode and noneDefined(selectedRecords, "deletedAt") and everyDefined(selectedRecords, "pageLayoutId") and objectPermissions.canUpdateObjectRecords', + 'pageType == "RECORD_PAGE" and isDashboardPageLayoutInEditMode and noneDefined(selectedRecords, "deletedAt") and everyDefined(selectedRecords, "pageLayoutId") and objectPermissions.canUpdateObjectRecords', availabilityObjectMetadataUniversalIdentifier: STANDARD_OBJECTS.dashboard.universalIdentifier, frontComponentUniversalIdentifier: null, diff --git a/packages/twenty-shared/src/types/CommandMenuContextApi.ts b/packages/twenty-shared/src/types/CommandMenuContextApi.ts index 6c4d37bf43..1167315545 100644 --- a/packages/twenty-shared/src/types/CommandMenuContextApi.ts +++ b/packages/twenty-shared/src/types/CommandMenuContextApi.ts @@ -5,7 +5,8 @@ import { type ObjectRecord } from './ObjectRecord'; export type CommandMenuContextApi = { pageType: ContextStorePageType; isInSidePanel: boolean; - isPageInEditMode: boolean; + isDashboardPageLayoutInEditMode: boolean; + isLayoutCustomizationModeEnabled: boolean; favoriteRecordIds: string[]; isSelectAll: boolean; hasAnySoftDeleteFilterOnView: boolean; diff --git a/packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts b/packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts index cdc25fa591..b16baf7efc 100644 --- a/packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts +++ b/packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts @@ -6,7 +6,8 @@ const buildContext = ( ): CommandMenuContextApi => ({ pageType: ContextStorePageType.Index, isInSidePanel: false, - isPageInEditMode: false, + isDashboardPageLayoutInEditMode: false, + isLayoutCustomizationModeEnabled: false, favoriteRecordIds: [], isSelectAll: false, hasAnySoftDeleteFilterOnView: false, diff --git a/packages/twenty-shared/src/utils/command-menu-items/__tests__/interpolateCommandMenuItemTemplate.test.ts b/packages/twenty-shared/src/utils/command-menu-items/__tests__/interpolateCommandMenuItemTemplate.test.ts index 77be0e952c..514a0e2bce 100644 --- a/packages/twenty-shared/src/utils/command-menu-items/__tests__/interpolateCommandMenuItemTemplate.test.ts +++ b/packages/twenty-shared/src/utils/command-menu-items/__tests__/interpolateCommandMenuItemTemplate.test.ts @@ -6,7 +6,8 @@ const buildContext = ( ): CommandMenuContextApi => ({ pageType: ContextStorePageType.Index, isInSidePanel: false, - isPageInEditMode: false, + isDashboardPageLayoutInEditMode: false, + isLayoutCustomizationModeEnabled: false, favoriteRecordIds: [], isSelectAll: false, hasAnySoftDeleteFilterOnView: false,