From afeef8e04a8fb3892fc5958b43b9617ce861b632 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Thu, 26 Mar 2026 20:34:03 +0530 Subject: [PATCH] feat: add command menu item to layout customization (#18764) figma https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=93630-365000&t=QesAkY3JOyI9D3UU-0 https://github.com/user-attachments/assets/cf3b354d-0b08-41ae-91ae-386054b5cb2e https://github.com/user-attachments/assets/73bfd676-f823-44c9-a70d-107c20523664 --------- Co-authored-by: Charles Bochet --- packages/twenty-front/jest.config.mjs | 2 +- .../components/RecordIndexCommandMenu.tsx | 40 ++- .../components/RecordShowCommandMenu.tsx | 29 +- .../contexts/CommandMenuContextProvider.tsx | 2 +- .../CommandMenuContextProviderServerItems.tsx | 2 +- ...dMenuContextProviderServerItemsContent.tsx | 2 +- ...viderServerItemsWithWorkflowEnrichment.tsx | 2 +- .../mutations/updateCommandMenuItem.ts | 11 + .../hooks/useCommandMenuContextApi.ts | 42 +-- .../hooks/useCommandMenuItemsDraftState.ts | 34 ++ .../hooks/useCommandMenuItemsFromBackend.tsx | 238 ++++++++++++ ...vertBackendItemToCommandMenuItemConfig.tsx | 0 .../hooks/useWorkflowsWithCurrentVersions.ts | 0 .../common/states/commandMenuItemsSelector.ts | 14 + ...oesCommandMenuItemMatchObjectMetadataId.ts | 7 + .../CommandMenuItemMoreActionsButton.tsx | 64 ++++ .../PinnedCommandMenuItemButtons.tsx | 99 +++++ ...nnedCommandMenuItemsInlineMeasurements.tsx | 54 +++ .../SidePanelCommandMenuItemDisplayPage.tsx | 127 +++++++ .../constants/PinnedCommandMenuItemsGap.ts | 1 + .../usePinnedCommandMenuItemsInlineLayout.ts | 114 ++++++ .../commandMenuPinnedInlineLayoutState.ts | 11 + .../types/CommandMenuPinnedInlineLayout.ts | 4 + ...mandMenuItemCountForContainerWidth.test.ts | 39 ++ ...leCommandMenuItemCountForContainerWidth.ts | 42 +++ .../components/CommandMenuItemEditButton.tsx | 72 ++++ ...andMenuItemEditRecordSelectionDropdown.tsx | 107 ++++++ .../CommandMenuItemOptionsDropdown.tsx | 85 +++++ .../PinnedCommandMenuItemButtonsEditMode.tsx | 172 +++++++++ .../SidePanelCommandMenuItemEditPage.tsx | 339 ++++++++++++++++++ .../useReorderCommandMenuItemsInDraft.ts | 92 +++++ .../hooks/useResetCommandMenuItemsDraft.ts | 21 ++ .../hooks/useSaveCommandMenuItemsDraft.ts | 57 +++ .../hooks/useUpdateCommandMenuItemInDraft.ts | 29 ++ .../commandMenuItemEditSelectionModeState.ts | 9 + .../edit/states/commandMenuItemsDraftState.ts | 10 + .../types/CommandMenuItemEditableFields.ts | 6 + .../computeInsertPositionFromBounds.test.ts | 39 ++ .../getPositionBoundsAtInsertionPoint.test.ts | 105 ++++++ .../utils/computeInsertPositionFromBounds.ts | 24 ++ .../getPositionBoundsAtInsertionPoint.ts | 23 ++ .../hooks/useCommandMenuItemsFromBackend.tsx | 39 -- .../types/CommandMenuItemConfig.ts | 1 + .../__stories__/CommandMenu.stories.tsx | 2 +- .../hooks/__tests__/useCommandMenu.test.tsx | 12 +- .../hooks/useCommandMenuHotKeys.ts | 4 +- .../hooks/useEnterLayoutCustomizationMode.ts | 50 ++- .../hooks/useExitLayoutCustomizationMode.ts | 2 + .../hooks/useIsLayoutCustomizationDirty.ts | 7 +- .../hooks/useSaveLayoutCustomization.ts | 10 + .../hooks/useLoadStaleMetadataEntities.ts | 18 + .../types/FlatCommandMenuItem.ts | 4 +- .../components/RecordIndexPageHeader.tsx | 19 +- .../hooks/useEditPageLayoutWidget.ts | 2 +- .../side-panel/components/SidePanelTopBar.tsx | 6 +- .../SidePanelTopBarInputFocusEffect.tsx | 2 +- .../constants/SidePanelPagesConfig.tsx | 22 +- .../__tests__/useNavigateSidePanel.test.tsx | 14 +- ...anelCloseAnimationCompleteCleanup.test.tsx | 4 +- .../__tests__/useSidePanelHistory.test.tsx | 4 +- .../useUpdateSidePanelPageInfo.test.tsx | 8 +- ...eSidePanelCloseAnimationCompleteCleanup.ts | 2 +- .../hooks/useSidePanelContextChips.tsx | 2 +- .../side-panel/hooks/useSidePanelMenu.ts | 2 +- .../side-panel/states/sidePanelPageState.ts | 2 +- .../ui/layout/page/components/PageHeader.tsx | 17 +- .../pages/object-record/RecordShowPage.tsx | 20 +- .../standard-command-menu-item.constant.ts | 228 ++++++------ .../twenty-shared/src/types/SidePanelPages.ts | 3 +- .../display/icon/components/TablerIcons.ts | 4 + packages/twenty-ui/src/display/index.ts | 4 + .../components/AnimatedIconCrossfade.tsx | 69 ++++ .../src/utilities/animation/index.ts | 3 +- packages/twenty-ui/src/utilities/index.ts | 1 + 74 files changed, 2518 insertions(+), 239 deletions(-) rename packages/twenty-front/src/modules/command-menu-item/server-items/{ => common}/contexts/CommandMenuContextProviderServerItems.tsx (97%) rename packages/twenty-front/src/modules/command-menu-item/server-items/{ => common}/contexts/CommandMenuContextProviderServerItemsContent.tsx (94%) rename packages/twenty-front/src/modules/command-menu-item/server-items/{ => common}/contexts/CommandMenuContextProviderServerItemsWithWorkflowEnrichment.tsx (97%) create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/common/graphql/mutations/updateCommandMenuItem.ts rename packages/twenty-front/src/modules/command-menu-item/server-items/{ => common}/hooks/useCommandMenuContextApi.ts (86%) create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/common/hooks/useCommandMenuItemsDraftState.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/common/hooks/useCommandMenuItemsFromBackend.tsx rename packages/twenty-front/src/modules/command-menu-item/server-items/{ => common}/hooks/useConvertBackendItemToCommandMenuItemConfig.tsx (100%) rename packages/twenty-front/src/modules/command-menu-item/server-items/{ => common}/hooks/useWorkflowsWithCurrentVersions.ts (100%) create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/common/states/commandMenuItemsSelector.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/common/utils/doesCommandMenuItemMatchObjectMetadataId.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/display/components/CommandMenuItemMoreActionsButton.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/display/components/PinnedCommandMenuItemButtons.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/display/components/PinnedCommandMenuItemsInlineMeasurements.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/display/components/SidePanelCommandMenuItemDisplayPage.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/display/hooks/usePinnedCommandMenuItemsInlineLayout.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/display/states/commandMenuPinnedInlineLayoutState.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/display/types/CommandMenuPinnedInlineLayout.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/display/utils/__tests__/getVisibleCommandMenuItemCountForContainerWidth.test.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/display/utils/getVisibleCommandMenuItemCountForContainerWidth.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/CommandMenuItemEditButton.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/CommandMenuItemOptionsDropdown.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/PinnedCommandMenuItemButtonsEditMode.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/SidePanelCommandMenuItemEditPage.tsx create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/edit/hooks/useReorderCommandMenuItemsInDraft.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/edit/hooks/useResetCommandMenuItemsDraft.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/edit/hooks/useSaveCommandMenuItemsDraft.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/edit/hooks/useUpdateCommandMenuItemInDraft.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/edit/states/commandMenuItemEditSelectionModeState.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/edit/states/commandMenuItemsDraftState.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/edit/types/CommandMenuItemEditableFields.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/edit/utils/__tests__/computeInsertPositionFromBounds.test.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/edit/utils/__tests__/getPositionBoundsAtInsertionPoint.test.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/edit/utils/computeInsertPositionFromBounds.ts create mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/edit/utils/getPositionBoundsAtInsertionPoint.ts delete mode 100644 packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useCommandMenuItemsFromBackend.tsx create mode 100644 packages/twenty-ui/src/utilities/animation/components/AnimatedIconCrossfade.tsx diff --git a/packages/twenty-front/jest.config.mjs b/packages/twenty-front/jest.config.mjs index 4f30411717..ef2af16ffc 100644 --- a/packages/twenty-front/jest.config.mjs +++ b/packages/twenty-front/jest.config.mjs @@ -61,7 +61,7 @@ const jestConfig = { extensionsToTreatAsEsm: ['.ts', '.tsx'], coverageThreshold: { global: { - statements: 48.5, + statements: 48.4, lines: 47.0, functions: 39.5, }, diff --git a/packages/twenty-front/src/modules/command-menu-item/components/RecordIndexCommandMenu.tsx b/packages/twenty-front/src/modules/command-menu-item/components/RecordIndexCommandMenu.tsx index af91dd07ee..10adee45ad 100644 --- a/packages/twenty-front/src/modules/command-menu-item/components/RecordIndexCommandMenu.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/components/RecordIndexCommandMenu.tsx @@ -1,9 +1,16 @@ import { PageHeaderCommandMenuButtons } from '@/command-menu-item/components/PageHeaderCommandMenuButtons'; +import { PinnedCommandMenuItemButtons } from '@/command-menu-item/server-items/display/components/PinnedCommandMenuItemButtons'; import { RecordIndexCommandMenuDropdown } from '@/command-menu-item/components/RecordIndexCommandMenuDropdown'; import { CommandMenuContextProvider } from '@/command-menu-item/contexts/CommandMenuContextProvider'; +import { CommandMenuItemEditButton } from '@/command-menu-item/server-items/edit/components/CommandMenuItemEditButton'; +import { PinnedCommandMenuItemButtonsEditMode } from '@/command-menu-item/server-items/edit/components/PinnedCommandMenuItemButtonsEditMode'; import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId'; import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; +import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; import { useIsMobile } from 'twenty-ui/utilities'; export const RecordIndexCommandMenu = () => { @@ -13,18 +20,36 @@ export const RecordIndexCommandMenu = () => { ); const isMobile = useIsMobile(); + const isCommandMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + ); + const isLayoutCustomizationModeEnabled = useAtomStateValue( + isLayoutCustomizationModeEnabledState, + ); + + const showEditModePinnedButtons = + isCommandMenuItemEnabled && isLayoutCustomizationModeEnabled; return ( <> {contextStoreCurrentObjectMetadataItemId && ( <> - - {!isMobile && } - + {!isMobile && showEditModePinnedButtons ? ( + + ) : ( + + {!isMobile && + (isCommandMenuItemEnabled ? ( + + ) : ( + + ))} + + )} { > + )} diff --git a/packages/twenty-front/src/modules/command-menu-item/components/RecordShowCommandMenu.tsx b/packages/twenty-front/src/modules/command-menu-item/components/RecordShowCommandMenu.tsx index 425e9397be..06e8918568 100644 --- a/packages/twenty-front/src/modules/command-menu-item/components/RecordShowCommandMenu.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/components/RecordShowCommandMenu.tsx @@ -1,9 +1,13 @@ import { PageHeaderCommandMenuButtons } from '@/command-menu-item/components/PageHeaderCommandMenuButtons'; +import { PinnedCommandMenuItemButtons } from '@/command-menu-item/server-items/display/components/PinnedCommandMenuItemButtons'; import { CommandMenuContextProvider } from '@/command-menu-item/contexts/CommandMenuContextProvider'; +import { CommandMenuItemEditButton } from '@/command-menu-item/server-items/edit/components/CommandMenuItemEditButton'; import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId'; import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; import { useIsMobile } from 'twenty-ui/utilities'; export const RecordShowCommandMenu = () => { @@ -22,17 +26,28 @@ export const RecordShowCommandMenu = () => { contextStoreTargetedRecordsRule.selectedRecordIds.length === 1; const isMobile = useIsMobile(); + const isCommandMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + ); return ( <> {hasSelectedRecord && contextStoreCurrentObjectMetadataItemId && ( - - {!isMobile && } - + <> + + {!isMobile && + (isCommandMenuItemEnabled ? ( + + ) : ( + + ))} + + + )} ); diff --git a/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProvider.tsx b/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProvider.tsx index 28b4183762..ef0306d59f 100644 --- a/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProvider.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/contexts/CommandMenuContextProvider.tsx @@ -1,6 +1,6 @@ import { type CommandMenuContextType } from '@/command-menu-item/contexts/CommandMenuContext'; import { CommandMenuContextProviderLegacy } from '@/command-menu-item/contexts/CommandMenuContextProviderLegacy'; -import { CommandMenuContextProviderServerItems } from '@/command-menu-item/server-items/contexts/CommandMenuContextProviderServerItems'; +import { CommandMenuContextProviderServerItems } from '@/command-menu-item/server-items/common/contexts/CommandMenuContextProviderServerItems'; import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { FeatureFlagKey } from '~/generated-metadata/graphql'; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/contexts/CommandMenuContextProviderServerItems.tsx b/packages/twenty-front/src/modules/command-menu-item/server-items/common/contexts/CommandMenuContextProviderServerItems.tsx similarity index 97% rename from packages/twenty-front/src/modules/command-menu-item/server-items/contexts/CommandMenuContextProviderServerItems.tsx rename to packages/twenty-front/src/modules/command-menu-item/server-items/common/contexts/CommandMenuContextProviderServerItems.tsx index 7e525a58b5..05f3abcad9 100644 --- a/packages/twenty-front/src/modules/command-menu-item/server-items/contexts/CommandMenuContextProviderServerItems.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/common/contexts/CommandMenuContextProviderServerItems.tsx @@ -1,8 +1,8 @@ import { CoreObjectNameSingular } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { useCommandMenuContextApi } from '@/command-menu-item/server-items/hooks/useCommandMenuContextApi'; import { type CommandMenuContextType } from '@/command-menu-item/contexts/CommandMenuContext'; +import { useCommandMenuContextApi } from '@/command-menu-item/server-items/common/hooks/useCommandMenuContextApi'; import { CommandMenuContextProviderServerItemsContent } from './CommandMenuContextProviderServerItemsContent'; import { CommandMenuContextProviderServerItemsWithWorkflowEnrichment } from './CommandMenuContextProviderServerItemsWithWorkflowEnrichment'; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/contexts/CommandMenuContextProviderServerItemsContent.tsx b/packages/twenty-front/src/modules/command-menu-item/server-items/common/contexts/CommandMenuContextProviderServerItemsContent.tsx similarity index 94% rename from packages/twenty-front/src/modules/command-menu-item/server-items/contexts/CommandMenuContextProviderServerItemsContent.tsx rename to packages/twenty-front/src/modules/command-menu-item/server-items/common/contexts/CommandMenuContextProviderServerItemsContent.tsx index f1e334b7c3..e8af6c9b97 100644 --- a/packages/twenty-front/src/modules/command-menu-item/server-items/contexts/CommandMenuContextProviderServerItemsContent.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/common/contexts/CommandMenuContextProviderServerItemsContent.tsx @@ -2,7 +2,7 @@ import { CommandMenuContext, type CommandMenuContextType, } from '@/command-menu-item/contexts/CommandMenuContext'; -import { useCommandMenuItemsFromBackend } from '@/command-menu-item/server-items/hooks/useCommandMenuItemsFromBackend'; +import { useCommandMenuItemsFromBackend } from '@/command-menu-item/server-items/common/hooks/useCommandMenuItemsFromBackend'; import { type CommandMenuContextApi } from 'twenty-shared/types'; type CommandMenuContextProviderServerItemsContentProps = { diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/contexts/CommandMenuContextProviderServerItemsWithWorkflowEnrichment.tsx b/packages/twenty-front/src/modules/command-menu-item/server-items/common/contexts/CommandMenuContextProviderServerItemsWithWorkflowEnrichment.tsx similarity index 97% rename from packages/twenty-front/src/modules/command-menu-item/server-items/contexts/CommandMenuContextProviderServerItemsWithWorkflowEnrichment.tsx rename to packages/twenty-front/src/modules/command-menu-item/server-items/common/contexts/CommandMenuContextProviderServerItemsWithWorkflowEnrichment.tsx index 871903e040..63e7769d38 100644 --- a/packages/twenty-front/src/modules/command-menu-item/server-items/contexts/CommandMenuContextProviderServerItemsWithWorkflowEnrichment.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/common/contexts/CommandMenuContextProviderServerItemsWithWorkflowEnrichment.tsx @@ -2,7 +2,7 @@ import { type CommandMenuContextApi } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { type CommandMenuContextType } from '@/command-menu-item/contexts/CommandMenuContext'; -import { useWorkflowsWithCurrentVersions } from '@/command-menu-item/server-items/hooks/useWorkflowsWithCurrentVersions'; +import { useWorkflowsWithCurrentVersions } from '@/command-menu-item/server-items/common/hooks/useWorkflowsWithCurrentVersions'; import { CommandMenuContextProviderServerItemsContent } from './CommandMenuContextProviderServerItemsContent'; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/common/graphql/mutations/updateCommandMenuItem.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/common/graphql/mutations/updateCommandMenuItem.ts new file mode 100644 index 0000000000..bfd3581750 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/common/graphql/mutations/updateCommandMenuItem.ts @@ -0,0 +1,11 @@ +import { COMMAND_MENU_ITEM_FRAGMENT } from '@/command-menu-item/graphql/fragments/commandMenuItemFragment'; +import { gql } from '@apollo/client'; + +export const UPDATE_COMMAND_MENU_ITEM = gql` + ${COMMAND_MENU_ITEM_FRAGMENT} + mutation UpdateCommandMenuItem($input: UpdateCommandMenuItemInput!) { + updateCommandMenuItem(input: $input) { + ...CommandMenuItemFields + } + } +`; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useCommandMenuContextApi.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/common/hooks/useCommandMenuContextApi.ts similarity index 86% rename from packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useCommandMenuContextApi.ts rename to packages/twenty-front/src/modules/command-menu-item/server-items/common/hooks/useCommandMenuContextApi.ts index 3d5a4d9b6c..b1eedbe80f 100644 --- a/packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useCommandMenuContextApi.ts +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/common/hooks/useCommandMenuContextApi.ts @@ -1,6 +1,6 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { objectPermissionsFamilySelector } from '@/auth/states/objectPermissionsFamilySelector'; -import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext'; +import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState'; import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState'; @@ -13,13 +13,14 @@ import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPe import { hasAnySoftDeleteFilterOnViewComponentSelector } from '@/object-record/record-filter/states/hasAnySoftDeleteFilterOnView'; import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore'; import { recordStoreRecordsSelector } from '@/object-record/record-store/states/selectors/recordStoreRecordsSelector'; +import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { isNonEmptyArray } from '@sniptt/guards'; import { useStore } from 'jotai'; -import { useContext } from 'react'; import { CommandMenuContextApiPageType, type CommandMenuContextApi, @@ -29,7 +30,11 @@ import { isDefined } from 'twenty-shared/utils'; export const useCommandMenuContextApi = (): CommandMenuContextApi => { const store = useStore(); - const { isInSidePanel } = useContext(CommandMenuContext); + const contextStoreInstanceId = useAvailableComponentInstanceIdOrThrow( + ContextStoreComponentInstanceContext, + ); + const isInSidePanel = + contextStoreInstanceId === SIDE_PANEL_COMPONENT_INSTANCE_ID; const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue( contextStoreCurrentObjectMetadataItemIdComponentState, @@ -39,6 +44,10 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => { contextStoreTargetedRecordsRuleComponentState, ); + const contextStoreNumberOfSelectedRecords = useAtomComponentStateValue( + contextStoreNumberOfSelectedRecordsComponentState, + ); + const { objectMetadataItems } = useObjectMetadataItems(); const objectMetadataItem = objectMetadataItems.find( @@ -52,19 +61,16 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => { ? contextStoreTargetedRecordsRule.selectedRecordIds : undefined; - const favoriteRecordIds = (() => { - if (!isNonEmptyArray(recordIds) || !isDefined(objectMetadataItem)) { - return []; - } - - return recordIds.filter((recordId) => - navigationMenuItems?.some( - (item) => - item.targetRecordId === recordId && - item.targetObjectMetadataId === objectMetadataItem.id, - ), - ); - })(); + const favoriteRecordIds = + !isNonEmptyArray(recordIds) || !isDefined(objectMetadataItem) + ? [] + : recordIds.filter((recordId) => + navigationMenuItems?.some( + (item) => + item.targetRecordId === recordId && + item.targetObjectMetadataId === objectMetadataItem.id, + ), + ); const selectedRecords = useAtomFamilySelectorValue( recordStoreRecordsSelector, @@ -107,10 +113,6 @@ export const useCommandMenuContextApi = (): CommandMenuContextApi => { ? CommandMenuContextApiPageType.RECORD_PAGE : CommandMenuContextApiPageType.INDEX_PAGE; - const contextStoreNumberOfSelectedRecords = useAtomComponentStateValue( - contextStoreNumberOfSelectedRecordsComponentState, - ); - const isSelectAll = contextStoreTargetedRecordsRule.mode === 'exclusion'; const currentWorkspace = useAtomStateValue(currentWorkspaceState); diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/common/hooks/useCommandMenuItemsDraftState.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/common/hooks/useCommandMenuItemsDraftState.ts new file mode 100644 index 0000000000..e3ed9a6b11 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/common/hooks/useCommandMenuItemsDraftState.ts @@ -0,0 +1,34 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState'; +import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; + +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; + +export const useCommandMenuItemsDraftState = () => { + const commandMenuItems = useAtomStateValue(commandMenuItemsSelector); + const commandMenuItemsDraft = useAtomStateValue(commandMenuItemsDraftState); + + const isDirty = + isDefined(commandMenuItemsDraft) && + !isDeeplyEqual( + commandMenuItemsDraft.map(({ id, isPinned, position, shortLabel }) => ({ + id, + isPinned, + position, + shortLabel, + })), + commandMenuItems.map(({ id, isPinned, position, shortLabel }) => ({ + id, + isPinned, + position, + shortLabel, + })), + ); + + return { + commandMenuItemsDraft, + isDirty, + }; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/common/hooks/useCommandMenuItemsFromBackend.tsx b/packages/twenty-front/src/modules/command-menu-item/server-items/common/hooks/useCommandMenuItemsFromBackend.tsx new file mode 100644 index 0000000000..cac4d7b268 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/common/hooks/useCommandMenuItemsFromBackend.tsx @@ -0,0 +1,238 @@ +import { FrontComponentCommandMenuItem } from '@/command-menu-item/display/components/FrontComponentCommandMenuItem'; +import { HeadlessCommandMenuItem } from '@/command-menu-item/display/components/HeadlessCommandMenuItem'; +import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector'; +import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/server-items/common/utils/doesCommandMenuItemMatchObjectMetadataId'; +import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope'; +import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType'; + +import { type CommandMenuContextApi } from 'twenty-shared/types'; +import { + evaluateConditionalAvailabilityExpression, + interpolateCommandMenuItemLabel, + isDefined, +} from 'twenty-shared/utils'; +import { useIcons } from 'twenty-ui/display'; + +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon'; +import { + CommandMenuItemAvailabilityType, + type CommandMenuItemFieldsFragment, +} from '~/generated-metadata/graphql'; + +type CommandMenuItemWithFrontComponent = CommandMenuItemFieldsFragment & { + frontComponentId: string; + conditionalAvailabilityExpression?: string | null; +}; + +type CommandMenuItemWithSource = CommandMenuItemFieldsFragment & { + conditionalAvailabilityExpression?: string | null; +}; + +type BuildCommandMenuItemFromFrontComponentParams = { + item: CommandMenuItemWithFrontComponent; + type?: CommandMenuItemType; + scope: CommandMenuItemScope; + isPinned: boolean; + getIcon: ReturnType['getIcon']; + commandMenuContextApi: CommandMenuContextApi; +}; + +const buildCommandMenuItemFromFrontComponent = ({ + item, + type = CommandMenuItemType.FrontComponent, + scope, + isPinned, + getIcon, + commandMenuContextApi, +}: BuildCommandMenuItemFromFrontComponentParams) => { + const displayLabel = interpolateCommandMenuItemLabel({ + label: item.label, + context: commandMenuContextApi, + }); + + const displayShortLabel = interpolateCommandMenuItemLabel({ + label: item.shortLabel, + context: commandMenuContextApi, + }); + + const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON); + + const isHeadless = item.frontComponent?.isHeadless === true; + + return { + type, + key: `command-menu-item-front-component-${item.id}`, + id: item.id, + scope, + label: displayLabel, + shortLabel: displayShortLabel, + position: item.position, + isPinned, + Icon, + hotKeys: item.hotKeys, + shouldBeRegistered: () => + evaluateConditionalAvailabilityExpression( + item.conditionalAvailabilityExpression, + commandMenuContextApi, + ), + component: isHeadless ? ( + + ) : ( + + ), + }; +}; + +type BuildCommandMenuItemFromStandardKeyParams = { + item: CommandMenuItemWithSource; + type?: CommandMenuItemType; + scope: CommandMenuItemScope; + isPinned: boolean; + getIcon: ReturnType['getIcon']; + commandMenuContextApi: CommandMenuContextApi; +}; + +const buildCommandItemFromEngineKey = ({ + item, + type = CommandMenuItemType.Standard, + scope, + isPinned, + getIcon, + commandMenuContextApi, +}: BuildCommandMenuItemFromStandardKeyParams) => { + const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON); + + return { + type, + key: `command-menu-item-engine-${item.id}`, + id: item.id, + scope, + label: interpolateCommandMenuItemLabel({ + label: item.label, + context: commandMenuContextApi, + }), + shortLabel: interpolateCommandMenuItemLabel({ + label: item.shortLabel, + context: commandMenuContextApi, + }), + position: item.position, + isPinned, + Icon, + hotKeys: item.hotKeys, + shouldBeRegistered: () => + evaluateConditionalAvailabilityExpression( + item.conditionalAvailabilityExpression, + commandMenuContextApi, + ), + component: , + }; +}; + +export const useCommandMenuItemsFromBackend = ( + commandMenuContextApi: CommandMenuContextApi, +) => { + const { getIcon } = useIcons(); + const currentObjectMetadataItemId = + commandMenuContextApi.objectMetadataItem.id; + + const hasRecordSelection = commandMenuContextApi.numberOfSelectedRecords >= 1; + + const commandMenuItems = useAtomStateValue(commandMenuItemsSelector); + + const itemsWithObjectMatches = commandMenuItems.filter( + doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId), + ); + + const buildCommandMenuItem = ({ + item, + scope, + isPinned, + typeOverride, + }: { + item: CommandMenuItemFieldsFragment; + scope: CommandMenuItemScope; + isPinned: boolean; + typeOverride?: CommandMenuItemType; + }) => { + if (isDefined(item.engineComponentKey)) { + return buildCommandItemFromEngineKey({ + item, + type: typeOverride, + scope, + isPinned, + getIcon, + commandMenuContextApi, + }); + } + + if (isDefined(item.frontComponentId)) { + return buildCommandMenuItemFromFrontComponent({ + item: item as CommandMenuItemWithFrontComponent, + type: typeOverride, + scope, + isPinned, + getIcon, + commandMenuContextApi, + }); + } + + return null; + }; + + const globalItems = itemsWithObjectMatches.filter( + (item) => item.availabilityType === CommandMenuItemAvailabilityType.GLOBAL, + ); + + const recordScopedItems = itemsWithObjectMatches.filter( + (item) => + item.availabilityType === + CommandMenuItemAvailabilityType.RECORD_SELECTION, + ); + + const fallbackItems = itemsWithObjectMatches.filter( + (item) => + item.availabilityType === CommandMenuItemAvailabilityType.FALLBACK, + ); + + const globalCommandMenuItems = globalItems + .map((item) => + buildCommandMenuItem({ + item, + scope: CommandMenuItemScope.Global, + isPinned: item.isPinned, + }), + ) + .filter(isDefined); + + const recordScopedCommandMenuItems = hasRecordSelection + ? recordScopedItems + .map((item) => + buildCommandMenuItem({ + item, + scope: CommandMenuItemScope.RecordSelection, + isPinned: item.isPinned, + }), + ) + .filter(isDefined) + : []; + + const fallbackCommandMenuItems = fallbackItems + .map((item) => + buildCommandMenuItem({ + item, + scope: CommandMenuItemScope.Global, + isPinned: false, + typeOverride: CommandMenuItemType.Fallback, + }), + ) + .filter(isDefined); + + return [ + ...globalCommandMenuItems, + ...recordScopedCommandMenuItems, + ...fallbackCommandMenuItems, + ] + .filter((item) => item.shouldBeRegistered()) + .sort((a, b) => a.position - b.position); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useConvertBackendItemToCommandMenuItemConfig.tsx b/packages/twenty-front/src/modules/command-menu-item/server-items/common/hooks/useConvertBackendItemToCommandMenuItemConfig.tsx similarity index 100% rename from packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useConvertBackendItemToCommandMenuItemConfig.tsx rename to packages/twenty-front/src/modules/command-menu-item/server-items/common/hooks/useConvertBackendItemToCommandMenuItemConfig.tsx diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useWorkflowsWithCurrentVersions.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/common/hooks/useWorkflowsWithCurrentVersions.ts similarity index 100% rename from packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useWorkflowsWithCurrentVersions.ts rename to packages/twenty-front/src/modules/command-menu-item/server-items/common/hooks/useWorkflowsWithCurrentVersions.ts diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/common/states/commandMenuItemsSelector.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/common/states/commandMenuItemsSelector.ts new file mode 100644 index 0000000000..ce5ca08a40 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/common/states/commandMenuItemsSelector.ts @@ -0,0 +1,14 @@ +import { metadataStoreState } from '@/metadata-store/states/metadataStoreState'; +import { createAtomSelector } from '@/ui/utilities/state/jotai/utils/createAtomSelector'; +import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql'; + +export const commandMenuItemsSelector = createAtomSelector< + CommandMenuItemFieldsFragment[] +>({ + key: 'commandMenuItemsSelector', + get: ({ get }) => { + const storeItem = get(metadataStoreState, 'commandMenuItems'); + + return storeItem.current as CommandMenuItemFieldsFragment[]; + }, +}); diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/common/utils/doesCommandMenuItemMatchObjectMetadataId.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/common/utils/doesCommandMenuItemMatchObjectMetadataId.ts new file mode 100644 index 0000000000..62b8f19076 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/common/utils/doesCommandMenuItemMatchObjectMetadataId.ts @@ -0,0 +1,7 @@ +import { isDefined } from 'twenty-shared/utils'; +import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql'; + +export const doesCommandMenuItemMatchObjectMetadataId = + (objectMetadataItemId: unknown) => (item: CommandMenuItemFieldsFragment) => + !isDefined(item.availabilityObjectMetadataId) || + item.availabilityObjectMetadataId === objectMetadataItemId; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/display/components/CommandMenuItemMoreActionsButton.tsx b/packages/twenty-front/src/modules/command-menu-item/server-items/display/components/CommandMenuItemMoreActionsButton.tsx new file mode 100644 index 0000000000..602812372c --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/display/components/CommandMenuItemMoreActionsButton.tsx @@ -0,0 +1,64 @@ +import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; +import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState'; +import { PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page-header/constants/PageHeaderSidePanelButtonClickOutsideId'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { styled } from '@linaria/react'; +import { i18n } from '@lingui/core'; +import { t } from '@lingui/core/macro'; +import { + AppTooltip, + IconLayoutSidebarRightExpand, + IconX, + TooltipDelay, + TooltipPosition, +} from 'twenty-ui/display'; +import { AnimatedButton } from 'twenty-ui/input'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { AnimatedIconCrossfade, useIsMobile } from 'twenty-ui/utilities'; + +const StyledTooltipWrapper = styled.div` + font-size: ${themeCssVariables.font.size.md}; +`; + +export const CommandMenuItemMoreActionsButton = () => { + const { toggleSidePanelMenu } = useSidePanelMenu(); + const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState); + + const isMobile = useIsMobile(); + + const ariaLabel = isSidePanelOpened + ? t`Close side panel` + : t`Open side panel`; + + return ( +
+ + } + dataClickOutsideId={PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID} + dataTestId="page-header-side-panel-button" + size={isMobile ? 'medium' : 'small'} + variant="secondary" + accent="default" + title={t`More`} + ariaLabel={ariaLabel} + onClick={toggleSidePanelMenu} + /> + + + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/display/components/PinnedCommandMenuItemButtons.tsx b/packages/twenty-front/src/modules/command-menu-item/server-items/display/components/PinnedCommandMenuItemButtons.tsx new file mode 100644 index 0000000000..5d9e0b6ac6 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/display/components/PinnedCommandMenuItemButtons.tsx @@ -0,0 +1,99 @@ +import { CommandMenuItemComponent } from '@/command-menu-item/display/components/CommandMenuItemComponent'; +import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext'; +import { PinnedCommandMenuItemsInlineMeasurements } from '@/command-menu-item/server-items/display/components/PinnedCommandMenuItemsInlineMeasurements'; +import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap'; +import { usePinnedCommandMenuItemsInlineLayout } from '@/command-menu-item/server-items/display/hooks/usePinnedCommandMenuItemsInlineLayout'; +import { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension'; +import { styled } from '@linaria/react'; +import { motion } from 'framer-motion'; +import { useContext, useMemo } from 'react'; +import { ThemeContext } from 'twenty-ui/theme-constants'; + +const StyledActionContainer = styled(motion.div)` + align-items: center; + display: flex; + justify-content: center; +`; + +const StyledWrapper = styled.div` + flex: 1 1 0; + min-width: 0; + overflow: hidden; +`; + +const StyledContainer = styled.div` + display: flex; + justify-content: flex-end; + min-width: 0; + width: 100%; +`; + +const StyledItemsContainer = styled.div` + display: flex; + gap: ${PINNED_COMMAND_MENU_ITEMS_GAP}px; + max-width: 100%; + overflow: hidden; +`; + +export const PinnedCommandMenuItemButtons = () => { + const { theme } = useContext(ThemeContext); + const { commandMenuItems } = useContext(CommandMenuContext); + + const pinnedCommandMenuItems = useMemo( + () => + commandMenuItems.filter( + (commandMenuItem) => commandMenuItem.isPinned === true, + ), + [commandMenuItems], + ); + + const { + pinnedInlineCommandMenuItems, + pinnedOverflowCommandMenuItems, + onContainerDimensionChange, + onCommandMenuItemDimensionChange, + } = usePinnedCommandMenuItemsInlineLayout({ + pinnedCommandMenuItems, + }); + + return ( + <> + + + + + + {pinnedInlineCommandMenuItems.map( + (pinnedInlineCommandMenuItem) => ( + + + + ), + )} + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/display/components/PinnedCommandMenuItemsInlineMeasurements.tsx b/packages/twenty-front/src/modules/command-menu-item/server-items/display/components/PinnedCommandMenuItemsInlineMeasurements.tsx new file mode 100644 index 0000000000..92997a7d0f --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/display/components/PinnedCommandMenuItemsInlineMeasurements.tsx @@ -0,0 +1,54 @@ +import { CommandMenuButton } from '@/command-menu/components/CommandMenuButton'; +import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig'; +import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap'; +import { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension'; +import { styled } from '@linaria/react'; + +type ElementDimensions = { + width: number; + height: number; +}; + +type PinnedCommandMenuItemsInlineMeasurementsProps = { + pinnedCommandMenuItems: CommandMenuItemConfig[]; + onPinnedCommandMenuItemDimensionChange: ( + commandMenuItemKey: string, + ) => (dimensions: ElementDimensions) => void; +}; + +const StyledHiddenMeasurementsContainer = styled.div` + display: flex; + gap: ${PINNED_COMMAND_MENU_ITEMS_GAP}px; + pointer-events: none; + position: absolute; + top: -9999px; + visibility: hidden; +`; + +export const PinnedCommandMenuItemsInlineMeasurements = ({ + pinnedCommandMenuItems, + onPinnedCommandMenuItemDimensionChange, +}: PinnedCommandMenuItemsInlineMeasurementsProps) => { + return ( + + {pinnedCommandMenuItems.map((pinnedCommandMenuItem) => ( + + + + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/display/components/SidePanelCommandMenuItemDisplayPage.tsx b/packages/twenty-front/src/modules/command-menu-item/server-items/display/components/SidePanelCommandMenuItemDisplayPage.tsx new file mode 100644 index 0000000000..03d35ab69f --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/display/components/SidePanelCommandMenuItemDisplayPage.tsx @@ -0,0 +1,127 @@ +import { CommandMenuContext } from '@/command-menu-item/contexts/CommandMenuContext'; +import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap'; +import { commandMenuPinnedInlineLayoutState } from '@/command-menu-item/server-items/display/states/commandMenuPinnedInlineLayoutState'; +import { getVisibleCommandMenuItemCountForContainerWidth } from '@/command-menu-item/server-items/display/utils/getVisibleCommandMenuItemCountForContainerWidth'; +import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; +import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup'; +import { SidePanelList } from '@/side-panel/components/SidePanelList'; +import { SIDE_PANEL_PREVIOUS_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelPreviousComponentInstanceId'; +import { SIDE_PANEL_RESET_CONTEXT_TO_SELECTION } from '@/side-panel/constants/SidePanelResetContextToSelection'; +import { SidePanelResetContextToSelectionButton } from '@/side-panel/pages/root/components/SidePanelResetContextToSelectionButton'; +import { useFilterActionsWithSidePanelSearch } from '@/side-panel/pages/root/hooks/useFilterActionsWithSidePanelSearch'; +import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState'; +import { type SidePanelCommandMenuItemGroupConfig } from '@/side-panel/types/SidePanelCommandMenuItemGroupConfig'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useLingui } from '@lingui/react/macro'; +import { useContext } from 'react'; +import { isNumber } from '@sniptt/guards'; +import { isDefined } from 'twenty-shared/utils'; + +export const SidePanelCommandMenuItemDisplayPage = () => { + const { t } = useLingui(); + + const sidePanelSearch = useAtomStateValue(sidePanelSearchState); + const { commandMenuItems } = useContext(CommandMenuContext); + const commandMenuPinnedInlineLayout = useAtomStateValue( + commandMenuPinnedInlineLayoutState, + ); + + const { filterActionsWithSidePanelSearch } = + useFilterActionsWithSidePanelSearch({ + sidePanelSearch, + }); + + const pinnedCommandMenuItems = commandMenuItems + .filter((commandMenuItem) => commandMenuItem.isPinned === true) + .sort( + (firstPinnedCommandMenuItem, secondPinnedCommandMenuItem) => + firstPinnedCommandMenuItem.position - + secondPinnedCommandMenuItem.position, + ); + + const unpinnedCommandMenuItems = commandMenuItems + .filter((commandMenuItem) => commandMenuItem.isPinned !== true) + .sort( + (firstUnpinnedCommandMenuItem, secondUnpinnedCommandMenuItem) => + firstUnpinnedCommandMenuItem.position - + secondUnpinnedCommandMenuItem.position, + ); + + const pinnedCommandMenuItemKeysInDisplayOrder = pinnedCommandMenuItems.map( + (pinnedCommandMenuItem) => pinnedCommandMenuItem.key, + ); + + const visiblePinnedCommandMenuItemCount = + getVisibleCommandMenuItemCountForContainerWidth({ + commandMenuItemKeysInDisplayOrder: + pinnedCommandMenuItemKeysInDisplayOrder, + commandMenuItemWidthsByKey: + commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey, + commandMenuItemsContainerWidth: + commandMenuPinnedInlineLayout.containerWidth, + commandMenuItemsGapWidth: PINNED_COMMAND_MENU_ITEMS_GAP, + }); + + const hasKnownPinnedInlineLayout = + commandMenuPinnedInlineLayout.containerWidth > 0 && + pinnedCommandMenuItemKeysInDisplayOrder.every((commandMenuItemKey) => + isNumber( + commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey[ + commandMenuItemKey + ], + ), + ); + + const pinnedOverflowCommandMenuItems = hasKnownPinnedInlineLayout + ? pinnedCommandMenuItems.slice(visiblePinnedCommandMenuItemCount) + : pinnedCommandMenuItems; + + const matchingPinnedItems = filterActionsWithSidePanelSearch( + pinnedOverflowCommandMenuItems, + ); + const matchingOtherItems = filterActionsWithSidePanelSearch( + unpinnedCommandMenuItems, + ); + + const noResults = !matchingPinnedItems.length && !matchingOtherItems.length; + + const commandGroups: SidePanelCommandMenuItemGroupConfig[] = [ + { + heading: t`Pinned`, + items: matchingPinnedItems, + }, + { + heading: t`Other`, + items: matchingOtherItems, + }, + ]; + + const selectableItems = commandGroups.flatMap((group) => group.items ?? []); + const selectableItemIds = selectableItems.map((item) => item.key); + + // oxlint-disable-next-line twenty/matching-state-variable + const previousContextStoreCurrentObjectMetadataItemId = + useAtomComponentStateValue( + contextStoreCurrentObjectMetadataItemIdComponentState, + SIDE_PANEL_PREVIOUS_COMPONENT_INSTANCE_ID, + ); + + if (isDefined(previousContextStoreCurrentObjectMetadataItemId)) { + selectableItemIds.unshift(SIDE_PANEL_RESET_CONTEXT_TO_SELECTION); + } + + return ( + + {isDefined(previousContextStoreCurrentObjectMetadataItemId) && ( + + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap.ts new file mode 100644 index 0000000000..12540d15e9 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap.ts @@ -0,0 +1 @@ +export const PINNED_COMMAND_MENU_ITEMS_GAP = 8; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/display/hooks/usePinnedCommandMenuItemsInlineLayout.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/display/hooks/usePinnedCommandMenuItemsInlineLayout.ts new file mode 100644 index 0000000000..c2eac684bf --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/display/hooks/usePinnedCommandMenuItemsInlineLayout.ts @@ -0,0 +1,114 @@ +import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig'; +import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap'; +import { commandMenuPinnedInlineLayoutState } from '@/command-menu-item/server-items/display/states/commandMenuPinnedInlineLayoutState'; +import { getVisibleCommandMenuItemCountForContainerWidth } from '@/command-menu-item/server-items/display/utils/getVisibleCommandMenuItemCountForContainerWidth'; +import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState'; +import { useCallback, useMemo } from 'react'; + +type ElementDimensions = { + width: number; + height: number; +}; + +type UsePinnedCommandMenuItemsInlineLayoutParams = { + pinnedCommandMenuItems: CommandMenuItemConfig[]; +}; + +export const usePinnedCommandMenuItemsInlineLayout = ({ + pinnedCommandMenuItems, +}: UsePinnedCommandMenuItemsInlineLayoutParams) => { + const [commandMenuPinnedInlineLayout, setCommandMenuPinnedInlineLayout] = + useAtomState(commandMenuPinnedInlineLayoutState); + + const pinnedCommandMenuItemsSortedByPosition = useMemo( + () => + [...pinnedCommandMenuItems].sort( + (firstPinnedCommandMenuItem, secondPinnedCommandMenuItem) => + firstPinnedCommandMenuItem.position - + secondPinnedCommandMenuItem.position, + ), + [pinnedCommandMenuItems], + ); + + const pinnedCommandMenuItemKeysInDisplayOrder = useMemo( + () => + pinnedCommandMenuItemsSortedByPosition.map( + (pinnedCommandMenuItem) => pinnedCommandMenuItem.key, + ), + [pinnedCommandMenuItemsSortedByPosition], + ); + + const visiblePinnedCommandMenuItemCount = useMemo( + () => + getVisibleCommandMenuItemCountForContainerWidth({ + commandMenuItemKeysInDisplayOrder: + pinnedCommandMenuItemKeysInDisplayOrder, + commandMenuItemWidthsByKey: + commandMenuPinnedInlineLayout.commandMenuItemWidthsByKey, + commandMenuItemsContainerWidth: + commandMenuPinnedInlineLayout.containerWidth, + commandMenuItemsGapWidth: PINNED_COMMAND_MENU_ITEMS_GAP, + }), + [commandMenuPinnedInlineLayout, pinnedCommandMenuItemKeysInDisplayOrder], + ); + + const pinnedInlineCommandMenuItems = useMemo( + () => + pinnedCommandMenuItemsSortedByPosition.slice( + 0, + visiblePinnedCommandMenuItemCount, + ), + [pinnedCommandMenuItemsSortedByPosition, visiblePinnedCommandMenuItemCount], + ); + + const pinnedOverflowCommandMenuItems = useMemo( + () => + pinnedCommandMenuItemsSortedByPosition.slice( + visiblePinnedCommandMenuItemCount, + ), + [pinnedCommandMenuItemsSortedByPosition, visiblePinnedCommandMenuItemCount], + ); + + const onContainerDimensionChange = useCallback( + (dimensions: ElementDimensions) => { + setCommandMenuPinnedInlineLayout( + (previousCommandMenuPinnedInlineLayout) => + previousCommandMenuPinnedInlineLayout.containerWidth !== + dimensions.width + ? { + ...previousCommandMenuPinnedInlineLayout, + containerWidth: dimensions.width, + } + : previousCommandMenuPinnedInlineLayout, + ); + }, + [setCommandMenuPinnedInlineLayout], + ); + + const onCommandMenuItemDimensionChange = useCallback( + (commandMenuItemKey: string) => (dimensions: ElementDimensions) => { + setCommandMenuPinnedInlineLayout( + (previousCommandMenuPinnedInlineLayout) => + previousCommandMenuPinnedInlineLayout.commandMenuItemWidthsByKey[ + commandMenuItemKey + ] !== dimensions.width + ? { + ...previousCommandMenuPinnedInlineLayout, + commandMenuItemWidthsByKey: { + ...previousCommandMenuPinnedInlineLayout.commandMenuItemWidthsByKey, + [commandMenuItemKey]: dimensions.width, + }, + } + : previousCommandMenuPinnedInlineLayout, + ); + }, + [setCommandMenuPinnedInlineLayout], + ); + + return { + pinnedInlineCommandMenuItems, + pinnedOverflowCommandMenuItems, + onContainerDimensionChange, + onCommandMenuItemDimensionChange, + }; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/display/states/commandMenuPinnedInlineLayoutState.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/display/states/commandMenuPinnedInlineLayoutState.ts new file mode 100644 index 0000000000..865435e946 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/display/states/commandMenuPinnedInlineLayoutState.ts @@ -0,0 +1,11 @@ +import { type CommandMenuPinnedInlineLayout } from '@/command-menu-item/server-items/display/types/CommandMenuPinnedInlineLayout'; +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const commandMenuPinnedInlineLayoutState = + createAtomState({ + key: 'commandMenuPinnedInlineLayoutState', + defaultValue: { + containerWidth: 0, + commandMenuItemWidthsByKey: {}, + }, + }); diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/display/types/CommandMenuPinnedInlineLayout.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/display/types/CommandMenuPinnedInlineLayout.ts new file mode 100644 index 0000000000..abf70f0896 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/display/types/CommandMenuPinnedInlineLayout.ts @@ -0,0 +1,4 @@ +export type CommandMenuPinnedInlineLayout = { + containerWidth: number; + commandMenuItemWidthsByKey: Record; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/display/utils/__tests__/getVisibleCommandMenuItemCountForContainerWidth.test.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/display/utils/__tests__/getVisibleCommandMenuItemCountForContainerWidth.test.ts new file mode 100644 index 0000000000..ae2b7a0d80 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/display/utils/__tests__/getVisibleCommandMenuItemCountForContainerWidth.test.ts @@ -0,0 +1,39 @@ +import { getVisibleCommandMenuItemCountForContainerWidth } from '@/command-menu-item/server-items/display/utils/getVisibleCommandMenuItemCountForContainerWidth'; + +describe('getVisibleCommandMenuItemCountForContainerWidth', () => { + it('should return all items when container width is zero', () => { + const visibleCommandMenuItemCount = + getVisibleCommandMenuItemCountForContainerWidth({ + commandMenuItemKeysInDisplayOrder: ['a', 'b', 'c'], + commandMenuItemWidthsByKey: { a: 20, b: 20, c: 20 }, + commandMenuItemsContainerWidth: 0, + commandMenuItemsGapWidth: 8, + }); + + expect(visibleCommandMenuItemCount).toBe(3); + }); + + it('should return all items when at least one width is missing', () => { + const visibleCommandMenuItemCount = + getVisibleCommandMenuItemCountForContainerWidth({ + commandMenuItemKeysInDisplayOrder: ['a', 'b'], + commandMenuItemWidthsByKey: { a: 20 }, + commandMenuItemsContainerWidth: 40, + commandMenuItemsGapWidth: 8, + }); + + expect(visibleCommandMenuItemCount).toBe(2); + }); + + it('should stop before exceeding the container width with gaps', () => { + const visibleCommandMenuItemCount = + getVisibleCommandMenuItemCountForContainerWidth({ + commandMenuItemKeysInDisplayOrder: ['a', 'b', 'c'], + commandMenuItemWidthsByKey: { a: 40, b: 40, c: 40 }, + commandMenuItemsContainerWidth: 100, + commandMenuItemsGapWidth: 8, + }); + + expect(visibleCommandMenuItemCount).toBe(2); + }); +}); diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/display/utils/getVisibleCommandMenuItemCountForContainerWidth.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/display/utils/getVisibleCommandMenuItemCountForContainerWidth.ts new file mode 100644 index 0000000000..a1c8816a2d --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/display/utils/getVisibleCommandMenuItemCountForContainerWidth.ts @@ -0,0 +1,42 @@ +type GetVisibleCommandMenuItemCountForContainerWidthParams = { + commandMenuItemKeysInDisplayOrder: string[]; + commandMenuItemWidthsByKey: Record; + commandMenuItemsContainerWidth: number; + commandMenuItemsGapWidth: number; +}; + +export const getVisibleCommandMenuItemCountForContainerWidth = ({ + commandMenuItemKeysInDisplayOrder, + commandMenuItemWidthsByKey, + commandMenuItemsContainerWidth, + commandMenuItemsGapWidth, +}: GetVisibleCommandMenuItemCountForContainerWidthParams): number => { + if (commandMenuItemsContainerWidth <= 0) { + return commandMenuItemKeysInDisplayOrder.length; + } + + let usedWidth = 0; + let visibleCommandMenuItemCount = 0; + + for (const commandMenuItemKey of commandMenuItemKeysInDisplayOrder) { + const commandMenuItemWidth = commandMenuItemWidthsByKey[commandMenuItemKey]; + + if (typeof commandMenuItemWidth !== 'number') { + return commandMenuItemKeysInDisplayOrder.length; + } + + const nextWidth = + visibleCommandMenuItemCount === 0 + ? commandMenuItemWidth + : usedWidth + commandMenuItemsGapWidth + commandMenuItemWidth; + + if (nextWidth > commandMenuItemsContainerWidth) { + break; + } + + usedWidth = nextWidth; + visibleCommandMenuItemCount += 1; + } + + return visibleCommandMenuItemCount; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/CommandMenuItemEditButton.tsx b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/CommandMenuItemEditButton.tsx new file mode 100644 index 0000000000..4ff5c6ca66 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/CommandMenuItemEditButton.tsx @@ -0,0 +1,72 @@ +import { AnimatedIconCrossfade } from 'twenty-ui/utilities'; +import { commandMenuItemEditSelectionModeState } from '@/command-menu-item/server-items/edit/states/commandMenuItemEditSelectionModeState'; +import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState'; +import { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel'; +import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu'; +import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState'; +import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { useLingui } from '@lingui/react/macro'; +import { useStore } from 'jotai'; +import { SidePanelPages } from 'twenty-shared/types'; +import { IconPencil, IconX } from 'twenty-ui/display'; +import { AnimatedButton } from 'twenty-ui/input'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; + +export const CommandMenuItemEditButton = () => { + const { t } = useLingui(); + const store = useStore(); + const { navigateSidePanel } = useNavigateSidePanel(); + const { closeSidePanelMenu } = useSidePanelMenu(); + + const isLayoutCustomizationModeEnabled = useAtomStateValue( + isLayoutCustomizationModeEnabledState, + ); + const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState); + const sidePanelPage = useAtomStateValue(sidePanelPageState); + + const isCommandMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + ); + + const isCommandMenuEditPageActive = + isSidePanelOpened && sidePanelPage === SidePanelPages.CommandMenuEdit; + + if (!isLayoutCustomizationModeEnabled || !isCommandMenuItemEnabled) { + return null; + } + + const handleClick = () => { + if (isCommandMenuEditPageActive) { + closeSidePanelMenu(); + + return; + } + + store.set(commandMenuItemEditSelectionModeState.atom, 'selection'); + + navigateSidePanel({ + page: SidePanelPages.CommandMenuEdit, + pageTitle: t`Edit actions`, + pageIcon: IconPencil, + resetNavigationStack: true, + }); + }; + + return ( + + } + title={t`Edit actions`} + variant="secondary" + size="small" + onClick={handleClick} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx new file mode 100644 index 0000000000..780c221150 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/CommandMenuItemEditRecordSelectionDropdown.tsx @@ -0,0 +1,107 @@ +import { COMMAND_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/command-menu-item/constants/CommandMenuDropdownClickOutsideId'; +import { commandMenuItemEditSelectionModeState } from '@/command-menu-item/server-items/edit/states/commandMenuItemEditSelectionModeState'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState'; +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { + IconChevronDown, + IconSquareCheck, + IconSquareX, +} from 'twenty-ui/display'; +import { MenuItemSelect } from 'twenty-ui/navigation'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +const DROPDOWN_ID = 'command-menu-edit-record-selection-dropdown'; + +const StyledClickableArea = styled.div` + align-items: center; + background-color: ${themeCssVariables.background.transparent.lighter}; + border: 1px solid ${themeCssVariables.border.color.medium}; + border-radius: ${themeCssVariables.border.radius.sm}; + cursor: pointer; + display: flex; + gap: ${themeCssVariables.spacing[1]}; + height: 24px; + padding-left: ${themeCssVariables.spacing[2]}; + padding-right: ${themeCssVariables.spacing[1]}; +`; + +const StyledLabel = styled.span` + color: ${themeCssVariables.font.color.primary}; + font-size: ${themeCssVariables.font.size.md}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const StyledDropdownMenuContainer = styled.div` + width: 100%; +`; + +export const CommandMenuItemEditRecordSelectionDropdown = () => { + const { t } = useLingui(); + const { closeDropdown } = useCloseDropdown(); + + const commandMenuItemEditSelectionMode = useAtomStateValue( + commandMenuItemEditSelectionModeState, + ); + const setCommandMenuItemEditSelectionMode = useSetAtomState( + commandMenuItemEditSelectionModeState, + ); + + const isNoneSelected = commandMenuItemEditSelectionMode === 'none'; + + const handleSelectMode = (mode: 'none' | 'selection') => { + setCommandMenuItemEditSelectionMode(mode); + closeDropdown(DROPDOWN_ID); + }; + + const TriggerIcon = isNoneSelected ? IconSquareX : IconSquareCheck; + const triggerLabel = isNoneSelected + ? t`No record selected` + : t`Records selected`; + + return ( + + + {triggerLabel} + + + } + dropdownPlacement="bottom-start" + dropdownComponents={ + + + + handleSelectMode('none')} + /> + handleSelectMode('selection')} + /> + + + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/CommandMenuItemOptionsDropdown.tsx b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/CommandMenuItemOptionsDropdown.tsx new file mode 100644 index 0000000000..06f2307bef --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/CommandMenuItemOptionsDropdown.tsx @@ -0,0 +1,85 @@ +import { useUpdateCommandMenuItemInDraft } from '@/command-menu-item/server-items/edit/hooks/useUpdateCommandMenuItemInDraft'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { useLingui } from '@lingui/react/macro'; +import { type ReactElement } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { IconRefresh, IconTag } from 'twenty-ui/display'; +import { MenuItem, MenuItemToggle } from 'twenty-ui/navigation'; +import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql'; + +type CommandMenuItemOptionsDropdownProps = Pick< + CommandMenuItemFieldsFragment, + 'shortLabel' +> & { + itemId: string; + serverShortLabel: string | null | undefined; + iconButton: ReactElement; +}; + +const getCommandMenuItemOptionsDropdownId = (itemId: string) => + `command-menu-item-options-${itemId}`; + +export const CommandMenuItemOptionsDropdown = ({ + itemId, + shortLabel, + serverShortLabel, + iconButton, +}: CommandMenuItemOptionsDropdownProps) => { + const { t } = useLingui(); + + const dropdownId = getCommandMenuItemOptionsDropdownId(itemId); + const { closeDropdown } = useCloseDropdown(); + const { updateCommandMenuItemInDraft } = useUpdateCommandMenuItemInDraft(); + + const normalizedServerShortLabel = serverShortLabel ?? null; + const normalizedShortLabel = shortLabel ?? null; + const isLabelHidden = + normalizedShortLabel === null && isDefined(normalizedServerShortLabel); + const hasShortLabelOverride = + normalizedShortLabel !== normalizedServerShortLabel; + + const handleToggleHideLabel = (toggled: boolean) => { + updateCommandMenuItemInDraft(itemId, { + shortLabel: toggled ? null : normalizedServerShortLabel, + }); + }; + + const handleResetLabelToDefault = () => { + updateCommandMenuItemInDraft(itemId, { + shortLabel: normalizedServerShortLabel, + }); + closeDropdown(dropdownId); + }; + + return ( + + + + + + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/PinnedCommandMenuItemButtonsEditMode.tsx b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/PinnedCommandMenuItemButtonsEditMode.tsx new file mode 100644 index 0000000000..a2428ecd5d --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/PinnedCommandMenuItemButtonsEditMode.tsx @@ -0,0 +1,172 @@ +import { CommandMenuButton } from '@/command-menu/components/CommandMenuButton'; +import { useCommandMenuContextApi } from '@/command-menu-item/server-items/common/hooks/useCommandMenuContextApi'; +import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/server-items/common/utils/doesCommandMenuItemMatchObjectMetadataId'; +import { PinnedCommandMenuItemsInlineMeasurements } from '@/command-menu-item/server-items/display/components/PinnedCommandMenuItemsInlineMeasurements'; +import { PINNED_COMMAND_MENU_ITEMS_GAP } from '@/command-menu-item/server-items/display/constants/PinnedCommandMenuItemsGap'; +import { usePinnedCommandMenuItemsInlineLayout } from '@/command-menu-item/server-items/display/hooks/usePinnedCommandMenuItemsInlineLayout'; +import { commandMenuItemEditSelectionModeState } from '@/command-menu-item/server-items/edit/states/commandMenuItemEditSelectionModeState'; +import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState'; +import { CommandMenuItemScope } from '@/command-menu-item/types/CommandMenuItemScope'; +import { type CommandMenuItemConfig } from '@/command-menu-item/types/CommandMenuItemConfig'; +import { CommandMenuItemType } from '@/command-menu-item/types/CommandMenuItemType'; +import { NodeDimension } from '@/ui/utilities/dimensions/components/NodeDimension'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { COMMAND_MENU_DEFAULT_ICON } from '@/workflow/workflow-trigger/constants/CommandMenuDefaultIcon'; +import { styled } from '@linaria/react'; +import { motion } from 'framer-motion'; +import { useContext, useMemo } from 'react'; +import { interpolateCommandMenuItemLabel } from 'twenty-shared/utils'; +import { useIcons } from 'twenty-ui/display'; +import { ThemeContext } from 'twenty-ui/theme-constants'; +import { + CommandMenuItemAvailabilityType, + type CommandMenuItemFieldsFragment, +} from '~/generated-metadata/graphql'; + +const StyledActionContainer = styled(motion.div)` + align-items: center; + display: flex; + justify-content: center; +`; + +const StyledWrapper = styled.div` + flex: 1 1 0; + min-width: 0; + overflow: hidden; +`; + +const StyledContainer = styled.div` + display: flex; + justify-content: flex-end; + min-width: 0; + width: 100%; +`; + +const StyledItemsContainer = styled.div` + display: flex; + gap: ${PINNED_COMMAND_MENU_ITEMS_GAP}px; + max-width: 100%; + overflow: hidden; +`; + +export const PinnedCommandMenuItemButtonsEditMode = () => { + const { theme } = useContext(ThemeContext); + const { getIcon } = useIcons(); + const commandMenuContextApi = useCommandMenuContextApi(); + + const currentObjectMetadataItemId = + commandMenuContextApi.objectMetadataItem.id; + + const commandMenuItemsDraft = + useAtomStateValue(commandMenuItemsDraftState) ?? []; + const commandMenuItemEditSelectionMode = useAtomStateValue( + commandMenuItemEditSelectionModeState, + ); + + const allowedAvailabilityTypes = useMemo( + () => + new Set([ + CommandMenuItemAvailabilityType.GLOBAL, + commandMenuItemEditSelectionMode === 'selection' + ? CommandMenuItemAvailabilityType.RECORD_SELECTION + : CommandMenuItemAvailabilityType.FALLBACK, + ]), + [commandMenuItemEditSelectionMode], + ); + + const interpolateLabel = (rawLabel: string | null | undefined) => + interpolateCommandMenuItemLabel({ + label: rawLabel, + context: commandMenuContextApi, + }); + + const pinnedCommandMenuItems: CommandMenuItemConfig[] = useMemo( + () => + commandMenuItemsDraft + .filter( + doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId), + ) + .filter((item) => allowedAvailabilityTypes.has(item.availabilityType)) + .filter((item) => item.isPinned) + .map((item) => { + const Icon = getIcon(item.icon, COMMAND_MENU_DEFAULT_ICON); + const label = interpolateLabel(item.label) ?? item.label; + const shortLabel = interpolateLabel(item.shortLabel); + const key = `edit-preview-${item.id}`; + + return { + type: CommandMenuItemType.Standard, + scope: CommandMenuItemScope.Global, + key, + label, + shortLabel, + position: item.position, + Icon, + isPinned: true, + shouldBeRegistered: () => true, + component: ( + + ), + }; + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + commandMenuItemsDraft, + currentObjectMetadataItemId, + allowedAvailabilityTypes, + commandMenuContextApi, + getIcon, + ], + ); + + const { + pinnedInlineCommandMenuItems, + pinnedOverflowCommandMenuItems, + onContainerDimensionChange, + onCommandMenuItemDimensionChange, + } = usePinnedCommandMenuItemsInlineLayout({ + pinnedCommandMenuItems, + }); + + return ( + <> + + + + + + {pinnedInlineCommandMenuItems.map( + (pinnedInlineCommandMenuItem) => ( + + {pinnedInlineCommandMenuItem.component} + + ), + )} + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/SidePanelCommandMenuItemEditPage.tsx b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/SidePanelCommandMenuItemEditPage.tsx new file mode 100644 index 0000000000..9ef975c1b0 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/components/SidePanelCommandMenuItemEditPage.tsx @@ -0,0 +1,339 @@ +import { useCommandMenuContextApi } from '@/command-menu-item/server-items/common/hooks/useCommandMenuContextApi'; +import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector'; +import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/server-items/common/utils/doesCommandMenuItemMatchObjectMetadataId'; +import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState'; +import { CommandMenuItemEditRecordSelectionDropdown } from '@/command-menu-item/server-items/edit/components/CommandMenuItemEditRecordSelectionDropdown'; +import { CommandMenuItemOptionsDropdown } from '@/command-menu-item/server-items/edit/components/CommandMenuItemOptionsDropdown'; +import { useReorderCommandMenuItemsInDraft } from '@/command-menu-item/server-items/edit/hooks/useReorderCommandMenuItemsInDraft'; +import { useResetCommandMenuItemsDraft } from '@/command-menu-item/server-items/edit/hooks/useResetCommandMenuItemsDraft'; +import { commandMenuItemEditSelectionModeState } from '@/command-menu-item/server-items/edit/states/commandMenuItemEditSelectionModeState'; +import { useUpdateCommandMenuItemInDraft } from '@/command-menu-item/server-items/edit/hooks/useUpdateCommandMenuItemInDraft'; +import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId'; +import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup'; +import { SidePanelList } from '@/side-panel/components/SidePanelList'; +import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState'; +import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; +import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; +import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem'; +import { SidePanelFooter } from '@/ui/layout/side-panel/components/SidePanelFooter'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { type DropResult } from '@hello-pangea/dnd'; +import { styled } from '@linaria/react'; +import { useLingui } from '@lingui/react/macro'; +import { + interpolateCommandMenuItemLabel, + isDefined, +} from 'twenty-shared/utils'; +import { + IconDotsVertical, + IconPin, + IconPinnedOff, + IconRefresh, + useIcons, +} from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; +import { MenuItem, MenuItemDraggable } from 'twenty-ui/navigation'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { + CommandMenuItemAvailabilityType, + type CommandMenuItemFieldsFragment, +} from '~/generated-metadata/graphql'; +import { normalizeSearchText } from '~/utils/normalizeSearchText'; + +const partitionByPinned = (items: CommandMenuItemFieldsFragment[]) => { + const pinned = items + .filter((item) => item.isPinned) + .sort((a, b) => a.position - b.position); + const other = items + .filter((item) => !item.isPinned) + .sort((a, b) => a.position - b.position); + + return { pinned, other }; +}; + +const StyledContainer = styled.div` + display: flex; + flex-direction: column; + height: 100%; +`; + +const StyledViewbar = styled.div` + align-items: center; + backdrop-filter: blur(5px); + border-bottom: 1px solid ${themeCssVariables.border.color.light}; + display: flex; + flex-shrink: 0; + height: 40px; + padding: ${themeCssVariables.spacing[1]} ${themeCssVariables.spacing[2]}; +`; + +const StyledContent = styled.div` + flex: 1; + overflow: auto; +`; + +export const SidePanelCommandMenuItemEditPage = () => { + const { t } = useLingui(); + const { getIcon } = useIcons(); + const commandMenuContextApi = useCommandMenuContextApi(); + + const currentObjectMetadataItemId = + commandMenuContextApi.objectMetadataItem.id; + + const commandMenuItemEditSelectionMode = useAtomStateValue( + commandMenuItemEditSelectionModeState, + ); + + const sidePanelSearch = useAtomStateValue(sidePanelSearchState); + + const commandMenuItems = useAtomStateValue(commandMenuItemsSelector); + const serverItemsById = new Map( + commandMenuItems.map((item) => [item.id, item]), + ); + const commandMenuItemsDraft = + useAtomStateValue(commandMenuItemsDraftState) ?? []; + const { updateCommandMenuItemInDraft } = useUpdateCommandMenuItemInDraft(); + const { reorderCommandMenuItemInDraft } = useReorderCommandMenuItemsInDraft(); + const { resetCommandMenuItemsDraft } = useResetCommandMenuItemsDraft(); + + const allowedAvailabilityTypes = new Set([ + CommandMenuItemAvailabilityType.GLOBAL, + commandMenuItemEditSelectionMode === 'selection' + ? CommandMenuItemAvailabilityType.RECORD_SELECTION + : CommandMenuItemAvailabilityType.FALLBACK, + ]); + + const filteredCommandMenuItems = commandMenuItemsDraft + .filter( + doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId), + ) + .filter((item) => allowedAvailabilityTypes.has(item.availabilityType)); + + const filteredCommandMenuItemIds = new Set( + filteredCommandMenuItems.map((item) => item.id), + ); + + const getDisplayLabel = (item: CommandMenuItemFieldsFragment) => + interpolateCommandMenuItemLabel({ + label: item.label, + context: commandMenuContextApi, + }) ?? item.label; + + const { pinned: allPinnedItems, other: allOtherItems } = partitionByPinned( + filteredCommandMenuItems, + ); + + const normalizedSearch = + sidePanelSearch.length > 0 + ? normalizeSearchText(sidePanelSearch) + : undefined; + + const matchesSearch = (item: CommandMenuItemFieldsFragment) => + normalizedSearch === undefined || + normalizeSearchText(getDisplayLabel(item)).includes(normalizedSearch); + + const displayedPinnedItems = allPinnedItems.filter(matchesSearch); + const displayedOtherItems = allOtherItems.filter(matchesSearch); + + const selectableItemIds = [ + ...displayedPinnedItems.map((item) => item.id), + ...displayedOtherItems.map((item) => item.id), + ]; + + const handleTogglePin = (itemId: string, currentlyPinned: boolean) => { + if (currentlyPinned) { + const nextOtherPosition = + allOtherItems.length === 0 + ? 0 + : allOtherItems[allOtherItems.length - 1].position + 1; + + updateCommandMenuItemInDraft(itemId, { + isPinned: false, + position: nextOtherPosition, + }); + + return; + } + + const nextPinnedPosition = + allPinnedItems.length === 0 + ? 0 + : allPinnedItems[allPinnedItems.length - 1].position + 1; + + updateCommandMenuItemInDraft(itemId, { + isPinned: true, + position: nextPinnedPosition, + }); + }; + + const makeOptionsDropdownWrapper = + (item: Pick) => + ({ iconButton }: { iconButton: React.ReactElement }) => ( + + ); + + const handlePinnedDragEnd = (result: DropResult) => { + const { source, destination, draggableId } = result; + + if (!isDefined(destination)) { + return; + } + + if (source.index === destination.index) { + return; + } + + const displayedPinnedItemIdsWithoutSource = displayedPinnedItems + .map((item) => item.id) + .filter((itemId) => itemId !== draggableId); + + const allPinnedItemsWithoutSource = allPinnedItems.filter( + (item) => item.id !== draggableId, + ); + + const nextDisplayedPinnedItemId = + displayedPinnedItemIdsWithoutSource[destination.index]; + const previousDisplayedPinnedItemId = + displayedPinnedItemIdsWithoutSource[destination.index - 1]; + + let destinationIndexInAllPinnedItemsWithoutSource: number; + + if (isDefined(nextDisplayedPinnedItemId)) { + destinationIndexInAllPinnedItemsWithoutSource = + allPinnedItemsWithoutSource.findIndex( + (item) => item.id === nextDisplayedPinnedItemId, + ); + } else if (isDefined(previousDisplayedPinnedItemId)) { + const previousIndex = allPinnedItemsWithoutSource.findIndex( + (item) => item.id === previousDisplayedPinnedItemId, + ); + + if (previousIndex === -1) { + return; + } + + destinationIndexInAllPinnedItemsWithoutSource = previousIndex + 1; + } else { + return; + } + + if (destinationIndexInAllPinnedItemsWithoutSource === -1) { + return; + } + + reorderCommandMenuItemInDraft( + draggableId, + destinationIndexInAllPinnedItemsWithoutSource, + 'pinned', + filteredCommandMenuItemIds, + ); + }; + + return ( + + + + + + + + { + const ItemIcon = isDefined(item.icon) + ? getIcon(item.icon) + : undefined; + + return ( + handleTogglePin(item.id, true)} + > + { + event.stopPropagation(); + handleTogglePin(item.id, true); + }, + }, + { + Icon: IconDotsVertical, + Wrapper: makeOptionsDropdownWrapper(item), + onClick: () => {}, + }, + ]} + /> + + } + /> + ); + })} + /> + + + + {displayedOtherItems.map((item) => { + const ItemIcon = isDefined(item.icon) + ? getIcon(item.icon) + : undefined; + + return ( + handleTogglePin(item.id, false)} + > + { + event.stopPropagation(); + handleTogglePin(item.id, false); + }, + }, + ]} + /> + + ); + })} + + + + , + ]} + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/edit/hooks/useReorderCommandMenuItemsInDraft.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/hooks/useReorderCommandMenuItemsInDraft.ts new file mode 100644 index 0000000000..4c6a1a846c --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/hooks/useReorderCommandMenuItemsInDraft.ts @@ -0,0 +1,92 @@ +import { useStore } from 'jotai'; +import { useCallback } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState'; +import { computeInsertPositionFromBounds } from '@/command-menu-item/server-items/edit/utils/computeInsertPositionFromBounds'; +import { getPositionBoundsAtInsertionPoint } from '@/command-menu-item/server-items/edit/utils/getPositionBoundsAtInsertionPoint'; + +export const useReorderCommandMenuItemsInDraft = () => { + const store = useStore(); + + const reorderCommandMenuItemInDraft = useCallback( + ( + sourceId: string, + destinationIndex: number, + targetSection: 'pinned' | 'other', + contextualItemIds?: ReadonlySet, + ) => { + const draft = store.get(commandMenuItemsDraftState.atom); + + if (!isDefined(draft)) { + return; + } + + const isPinned = targetSection === 'pinned'; + + const fullSectionItems = draft + .filter((item) => item.isPinned === isPinned) + .sort((a, b) => a.position - b.position); + + const contextualSectionItems = isDefined(contextualItemIds) + ? fullSectionItems.filter((item) => contextualItemIds.has(item.id)) + : fullSectionItems; + + const fullSectionItemsWithoutSource = fullSectionItems.filter( + (item) => item.id !== sourceId, + ); + const contextualSectionItemsWithoutSource = contextualSectionItems.filter( + (item) => item.id !== sourceId, + ); + + const clampedDestinationIndex = Math.max( + 0, + Math.min(destinationIndex, contextualSectionItemsWithoutSource.length), + ); + + const nextContextualItem = + contextualSectionItemsWithoutSource[clampedDestinationIndex]; + const previousContextualItem = + contextualSectionItemsWithoutSource[clampedDestinationIndex - 1]; + + if ( + !isDefined(nextContextualItem) && + !isDefined(previousContextualItem) + ) { + return; + } + + const positionBounds = isDefined(nextContextualItem) + ? getPositionBoundsAtInsertionPoint( + nextContextualItem.id, + 'before', + fullSectionItemsWithoutSource, + ) + : getPositionBoundsAtInsertionPoint( + previousContextualItem!.id, + 'after', + fullSectionItemsWithoutSource, + ); + + if (!isDefined(positionBounds)) { + return; + } + + const newPosition = computeInsertPositionFromBounds( + positionBounds.previousPosition, + positionBounds.nextPosition, + ); + + const updatedDraft = draft.map((item) => + item.id === sourceId + ? { ...item, isPinned, position: newPosition } + : item, + ); + + store.set(commandMenuItemsDraftState.atom, updatedDraft); + }, + [store], + ); + + return { reorderCommandMenuItemInDraft }; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/edit/hooks/useResetCommandMenuItemsDraft.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/hooks/useResetCommandMenuItemsDraft.ts new file mode 100644 index 0000000000..a03bce9fe3 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/hooks/useResetCommandMenuItemsDraft.ts @@ -0,0 +1,21 @@ +import { useCallback } from 'react'; +import { useStore } from 'jotai'; + +import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState'; +import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector'; + +// Resets the draft to the current server state, discarding all user edits. +export const useResetCommandMenuItemsDraft = () => { + const store = useStore(); + + const resetCommandMenuItemsDraft = useCallback(() => { + const serverItems = store.get(commandMenuItemsSelector.atom); + + store.set( + commandMenuItemsDraftState.atom, + serverItems.map((item) => ({ ...item })), + ); + }, [store]); + + return { resetCommandMenuItemsDraft }; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/edit/hooks/useSaveCommandMenuItemsDraft.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/hooks/useSaveCommandMenuItemsDraft.ts new file mode 100644 index 0000000000..556b9bfa73 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/hooks/useSaveCommandMenuItemsDraft.ts @@ -0,0 +1,57 @@ +import { useCallback } from 'react'; +import { useStore } from 'jotai'; +import { useMutation } from '@apollo/client/react'; +import { isDefined } from 'twenty-shared/utils'; + +import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState'; +import { UPDATE_COMMAND_MENU_ITEM } from '@/command-menu-item/server-items/common/graphql/mutations/updateCommandMenuItem'; +import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { type UpdateCommandMenuItemInput } from '~/generated-metadata/graphql'; + +export const useSaveCommandMenuItemsDraft = () => { + const store = useStore(); + const [updateCommandMenuItem] = useMutation(UPDATE_COMMAND_MENU_ITEM); + const commandMenuItems = useAtomStateValue(commandMenuItemsSelector); + + const saveCommandMenuItemsDraft = useCallback(async () => { + const draft = store.get(commandMenuItemsDraftState.atom); + + if (!isDefined(draft)) { + return; + } + + const serverItemsById = new Map( + commandMenuItems.map((item) => [item.id, item]), + ); + + const changedItems = draft.filter((draftItem) => { + const serverItem = serverItemsById.get(draftItem.id); + + if (!isDefined(serverItem)) { + return false; + } + + return ( + draftItem.isPinned !== serverItem.isPinned || + draftItem.position !== serverItem.position || + draftItem.shortLabel !== serverItem.shortLabel + ); + }); + + await Promise.all( + changedItems.map((item) => { + const input: UpdateCommandMenuItemInput = { + id: item.id, + isPinned: item.isPinned, + position: item.position, + shortLabel: item.shortLabel, + }; + + return updateCommandMenuItem({ variables: { input } }); + }), + ); + }, [store, commandMenuItems, updateCommandMenuItem]); + + return { saveCommandMenuItemsDraft }; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/edit/hooks/useUpdateCommandMenuItemInDraft.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/hooks/useUpdateCommandMenuItemInDraft.ts new file mode 100644 index 0000000000..c6513eccd6 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/hooks/useUpdateCommandMenuItemInDraft.ts @@ -0,0 +1,29 @@ +import { useCallback } from 'react'; +import { useStore } from 'jotai'; +import { isDefined } from 'twenty-shared/utils'; + +import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState'; +import { type CommandMenuItemEditableFields } from '@/command-menu-item/server-items/edit/types/CommandMenuItemEditableFields'; + +export const useUpdateCommandMenuItemInDraft = () => { + const store = useStore(); + + const updateCommandMenuItemInDraft = useCallback( + (id: string, fields: Partial) => { + const draft = store.get(commandMenuItemsDraftState.atom); + + if (!isDefined(draft)) { + return; + } + + const updatedDraft = draft.map((item) => + item.id === id ? { ...item, ...fields } : item, + ); + + store.set(commandMenuItemsDraftState.atom, updatedDraft); + }, + [store], + ); + + return { updateCommandMenuItemInDraft }; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/edit/states/commandMenuItemEditSelectionModeState.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/states/commandMenuItemEditSelectionModeState.ts new file mode 100644 index 0000000000..5ee29846bc --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/states/commandMenuItemEditSelectionModeState.ts @@ -0,0 +1,9 @@ +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export type CommandMenuItemEditSelectionMode = 'none' | 'selection'; + +export const commandMenuItemEditSelectionModeState = + createAtomState({ + key: 'commandMenuItemEditSelectionModeState', + defaultValue: 'selection', + }); diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/edit/states/commandMenuItemsDraftState.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/states/commandMenuItemsDraftState.ts new file mode 100644 index 0000000000..3499c80c42 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/states/commandMenuItemsDraftState.ts @@ -0,0 +1,10 @@ +import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql'; + +import { createAtomState } from '@/ui/utilities/state/jotai/utils/createAtomState'; + +export const commandMenuItemsDraftState = createAtomState< + CommandMenuItemFieldsFragment[] | null +>({ + key: 'commandMenuItemsDraftState', + defaultValue: null, +}); diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/edit/types/CommandMenuItemEditableFields.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/types/CommandMenuItemEditableFields.ts new file mode 100644 index 0000000000..bbddd7141e --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/types/CommandMenuItemEditableFields.ts @@ -0,0 +1,6 @@ +import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql'; + +export type CommandMenuItemEditableFields = Pick< + CommandMenuItemFieldsFragment, + 'isPinned' | 'position' | 'shortLabel' +>; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/edit/utils/__tests__/computeInsertPositionFromBounds.test.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/utils/__tests__/computeInsertPositionFromBounds.test.ts new file mode 100644 index 0000000000..f7d54e10aa --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/utils/__tests__/computeInsertPositionFromBounds.test.ts @@ -0,0 +1,39 @@ +import { computeInsertPositionFromBounds } from '@/command-menu-item/server-items/edit/utils/computeInsertPositionFromBounds'; + +describe('computeInsertPositionFromBounds', () => { + it('returns midpoint when both bounds are defined', () => { + expect(computeInsertPositionFromBounds(2, 4)).toBe(3); + }); + + it('returns midpoint for non-integer result', () => { + expect(computeInsertPositionFromBounds(1, 2)).toBe(1.5); + }); + + it('returns previous - 1 when only next is undefined', () => { + expect(computeInsertPositionFromBounds(5, undefined)).toBe(6); + }); + + it('returns next - 1 when only previous is undefined', () => { + expect(computeInsertPositionFromBounds(undefined, 3)).toBe(2); + }); + + it('returns 0 when both bounds are undefined', () => { + expect(computeInsertPositionFromBounds(undefined, undefined)).toBe(0); + }); + + it('returns previous - 1 when bounds are equal', () => { + expect(computeInsertPositionFromBounds(5, 5)).toBe(4); + }); + + it('handles negative positions', () => { + expect(computeInsertPositionFromBounds(-4, -2)).toBe(-3); + }); + + it('handles zero as previous position', () => { + expect(computeInsertPositionFromBounds(0, 2)).toBe(1); + }); + + it('handles zero as next position', () => { + expect(computeInsertPositionFromBounds(-2, 0)).toBe(-1); + }); +}); diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/edit/utils/__tests__/getPositionBoundsAtInsertionPoint.test.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/utils/__tests__/getPositionBoundsAtInsertionPoint.test.ts new file mode 100644 index 0000000000..efb826167d --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/utils/__tests__/getPositionBoundsAtInsertionPoint.test.ts @@ -0,0 +1,105 @@ +import { getPositionBoundsAtInsertionPoint } from '@/command-menu-item/server-items/edit/utils/getPositionBoundsAtInsertionPoint'; + +const makeItems = (positions: number[]) => + positions.map((position, index) => ({ + id: `item-${index}`, + position, + })); + +describe('getPositionBoundsAtInsertionPoint', () => { + const items = makeItems([10, 20, 30, 40, 50]); + + describe('insert before', () => { + it('returns previous=undefined and next=10 when inserting before the first item', () => { + expect( + getPositionBoundsAtInsertionPoint('item-0', 'before', items), + ).toEqual({ + previousPosition: undefined, + nextPosition: 10, + }); + }); + + it('returns previous=10 and next=20 when inserting before the second item', () => { + expect( + getPositionBoundsAtInsertionPoint('item-1', 'before', items), + ).toEqual({ + previousPosition: 10, + nextPosition: 20, + }); + }); + + it('returns previous=40 and next=50 when inserting before the last item', () => { + expect( + getPositionBoundsAtInsertionPoint('item-4', 'before', items), + ).toEqual({ + previousPosition: 40, + nextPosition: 50, + }); + }); + }); + + describe('insert after', () => { + it('returns previous=10 and next=20 when inserting after the first item', () => { + expect( + getPositionBoundsAtInsertionPoint('item-0', 'after', items), + ).toEqual({ + previousPosition: 10, + nextPosition: 20, + }); + }); + + it('returns previous=50 and next=undefined when inserting after the last item', () => { + expect( + getPositionBoundsAtInsertionPoint('item-4', 'after', items), + ).toEqual({ + previousPosition: 50, + nextPosition: undefined, + }); + }); + + it('returns previous=30 and next=40 when inserting after the middle item', () => { + expect( + getPositionBoundsAtInsertionPoint('item-2', 'after', items), + ).toEqual({ + previousPosition: 30, + nextPosition: 40, + }); + }); + }); + + describe('edge cases', () => { + it('returns undefined when anchor item is not found', () => { + expect( + getPositionBoundsAtInsertionPoint('nonexistent', 'before', items), + ).toBeUndefined(); + }); + + it('handles single-item list inserting before', () => { + const singleItem = makeItems([5]); + + expect( + getPositionBoundsAtInsertionPoint('item-0', 'before', singleItem), + ).toEqual({ + previousPosition: undefined, + nextPosition: 5, + }); + }); + + it('handles single-item list inserting after', () => { + const singleItem = makeItems([5]); + + expect( + getPositionBoundsAtInsertionPoint('item-0', 'after', singleItem), + ).toEqual({ + previousPosition: 5, + nextPosition: undefined, + }); + }); + + it('returns undefined for empty list', () => { + expect( + getPositionBoundsAtInsertionPoint('item-0', 'before', []), + ).toBeUndefined(); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/edit/utils/computeInsertPositionFromBounds.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/utils/computeInsertPositionFromBounds.ts new file mode 100644 index 0000000000..407a9ca35c --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/utils/computeInsertPositionFromBounds.ts @@ -0,0 +1,24 @@ +import { isDefined } from 'twenty-shared/utils'; + +export const computeInsertPositionFromBounds = ( + previousPosition: number | undefined, + nextPosition: number | undefined, +): number => { + if (!isDefined(previousPosition) && isDefined(nextPosition)) { + return nextPosition - 1; + } + + if (isDefined(previousPosition) && !isDefined(nextPosition)) { + return previousPosition + 1; + } + + if (isDefined(previousPosition) && isDefined(nextPosition)) { + if (previousPosition === nextPosition) { + return previousPosition - 1; + } + + return (previousPosition + nextPosition) / 2; + } + + return 0; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/edit/utils/getPositionBoundsAtInsertionPoint.ts b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/utils/getPositionBoundsAtInsertionPoint.ts new file mode 100644 index 0000000000..2bc5684d24 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu-item/server-items/edit/utils/getPositionBoundsAtInsertionPoint.ts @@ -0,0 +1,23 @@ +type PositionedItem = { id: string; position: number }; + +export const getPositionBoundsAtInsertionPoint = ( + anchorItemId: string, + insertionSide: 'before' | 'after', + sectionItems: PositionedItem[], +) => { + const anchorIndex = sectionItems.findIndex( + (item) => item.id === anchorItemId, + ); + + if (anchorIndex === -1) { + return undefined; + } + + const insertIndex = + insertionSide === 'before' ? anchorIndex : anchorIndex + 1; + + return { + previousPosition: sectionItems[insertIndex - 1]?.position, + nextPosition: sectionItems[insertIndex]?.position, + }; +}; diff --git a/packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useCommandMenuItemsFromBackend.tsx b/packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useCommandMenuItemsFromBackend.tsx deleted file mode 100644 index 36cc6f43e5..0000000000 --- a/packages/twenty-front/src/modules/command-menu-item/server-items/hooks/useCommandMenuItemsFromBackend.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useConvertBackendItemToCommandMenuItemConfig } from '@/command-menu-item/server-items/hooks/useConvertBackendItemToCommandMenuItemConfig'; -import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState'; -import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; -import { useQuery } from '@apollo/client/react'; -import { type CommandMenuContextApi } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; -import { - type CommandMenuItemFieldsFragment, - FindManyCommandMenuItemsDocument, -} from '~/generated-metadata/graphql'; - -export const useCommandMenuItemsFromBackend = ( - commandMenuContextApi: CommandMenuContextApi, -) => { - const { convertBackendItemToCommandMenuItemConfig } = - useConvertBackendItemToCommandMenuItemConfig(); - - const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue( - contextStoreCurrentObjectMetadataItemIdComponentState, - ); - - const { data } = useQuery(FindManyCommandMenuItemsDocument); - - const allItems = data?.commandMenuItems ?? []; - - const objectMatches = (item: CommandMenuItemFieldsFragment) => - !isDefined(item.availabilityObjectMetadataId) || - item.availabilityObjectMetadataId === - contextStoreCurrentObjectMetadataItemId; - - return allItems - .filter(objectMatches) - .map((item) => - convertBackendItemToCommandMenuItemConfig(item, commandMenuContextApi), - ) - .filter(isDefined) - .filter((item) => item.shouldBeRegistered()) - .sort((a, b) => a.position - b.position); -}; diff --git a/packages/twenty-front/src/modules/command-menu-item/types/CommandMenuItemConfig.ts b/packages/twenty-front/src/modules/command-menu-item/types/CommandMenuItemConfig.ts index 30ef106b00..af2d171b03 100644 --- a/packages/twenty-front/src/modules/command-menu-item/types/CommandMenuItemConfig.ts +++ b/packages/twenty-front/src/modules/command-menu-item/types/CommandMenuItemConfig.ts @@ -14,6 +14,7 @@ export type CommandMenuItemConfig = { type: CommandMenuItemType; scope: CommandMenuItemScope; key: string; + id?: string; label: Nullable; shortLabel?: Nullable; description?: MessageDescriptor | string; diff --git a/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx index 893a2a2e36..d0a71561d0 100644 --- a/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx +++ b/packages/twenty-front/src/modules/command-menu/components/__stories__/CommandMenu.stories.tsx @@ -95,7 +95,7 @@ const meta: Meta = { }); jotaiStore.set(sidePanelNavigationStackState.atom, [ { - page: SidePanelPages.Root, + page: SidePanelPages.CommandMenuDisplay, pageTitle: 'Command Menu', pageIcon: IconDotsVertical, pageId: '1', diff --git a/packages/twenty-front/src/modules/command-menu/hooks/__tests__/useCommandMenu.test.tsx b/packages/twenty-front/src/modules/command-menu/hooks/__tests__/useCommandMenu.test.tsx index 8749049699..3986797cc0 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/__tests__/useCommandMenu.test.tsx +++ b/packages/twenty-front/src/modules/command-menu/hooks/__tests__/useCommandMenu.test.tsx @@ -83,14 +83,16 @@ describe('useSidePanelMenu', () => { act(() => { result.current.commandMenu.navigateSidePanelMenu({ - page: SidePanelPages.Root, + page: SidePanelPages.CommandMenuDisplay, pageTitle: 'First Page', pageIcon: IconDotsVertical, resetNavigationStack: false, }); }); - expect(jotaiStore.get(sidePanelPageState.atom)).toBe(SidePanelPages.Root); + expect(jotaiStore.get(sidePanelPageState.atom)).toBe( + SidePanelPages.CommandMenuDisplay, + ); expect(jotaiStore.get(sidePanelPageInfoState.atom).title).toBe( 'First Page', ); @@ -109,14 +111,16 @@ describe('useSidePanelMenu', () => { act(() => { result.current.commandMenu.navigateSidePanelMenu({ - page: SidePanelPages.Root, + page: SidePanelPages.CommandMenuDisplay, pageTitle: 'Reset Page', pageIcon: IconDotsVertical, resetNavigationStack: true, }); }); - expect(jotaiStore.get(sidePanelPageState.atom)).toBe(SidePanelPages.Root); + expect(jotaiStore.get(sidePanelPageState.atom)).toBe( + SidePanelPages.CommandMenuDisplay, + ); expect(jotaiStore.get(sidePanelPageInfoState.atom).title).toBe( 'Reset Page', ); diff --git a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts index 8a43ea1931..73199d2b71 100644 --- a/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts +++ b/packages/twenty-front/src/modules/command-menu/hooks/useCommandMenuHotKeys.ts @@ -100,7 +100,7 @@ export const useCommandMenuHotKeys = () => { } if ( - sidePanelPage === SidePanelPages.Root && + sidePanelPage === SidePanelPages.CommandMenuDisplay && !( contextStoreTargetedRecordsRule.mode === 'selection' && contextStoreTargetedRecordsRule.selectedRecordIds.length === 0 @@ -108,7 +108,7 @@ export const useCommandMenuHotKeys = () => { ) { setGlobalCommandMenuContext(); } - if (sidePanelPage !== SidePanelPages.Root) { + if (sidePanelPage !== SidePanelPages.CommandMenuDisplay) { goBackOneSubPageOrMainPage(); } }, 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 ee013c4721..7a923993c4 100644 --- a/packages/twenty-front/src/modules/layout-customization/hooks/useEnterLayoutCustomizationMode.ts +++ b/packages/twenty-front/src/modules/layout-customization/hooks/useEnterLayoutCustomizationMode.ts @@ -1,13 +1,30 @@ -import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState'; -import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState'; -import { filterWorkspaceNavigationMenuItems } from '@/navigation-menu-item/common/utils/filterWorkspaceNavigationMenuItems'; -import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState'; -import { navigationMenuItemsSelector } from '@/navigation-menu-item/common/states/navigationMenuItemsSelector'; +import { t } from '@lingui/core/macro'; import { useStore } from 'jotai'; import { useCallback } from 'react'; +import { SidePanelPages } from 'twenty-shared/types'; +import { IconPencil } from 'twenty-ui/display'; + +import { commandMenuItemEditSelectionModeState } from '@/command-menu-item/server-items/edit/states/commandMenuItemEditSelectionModeState'; +import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState'; +import { commandMenuItemsSelector } from '@/command-menu-item/server-items/common/states/commandMenuItemsSelector'; +import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState'; +import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState'; +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 { useNavigateSidePanel } from '@/side-panel/hooks/useNavigateSidePanel'; +import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState'; +import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; + +import { FeatureFlagKey } from '~/generated-metadata/graphql'; export const useEnterLayoutCustomizationMode = () => { const store = useStore(); + const { navigateSidePanel } = useNavigateSidePanel(); + const isCommandMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + ); const enterLayoutCustomizationMode = useCallback(() => { const isLayoutCustomizationModeAlreadyEnabled = store.get( @@ -26,10 +43,31 @@ export const useEnterLayoutCustomizationMode = () => { ); store.set(navigationMenuItemsDraftState.atom, workspaceNavigationMenuItems); + const persistedCommandMenuItems = store.get(commandMenuItemsSelector.atom); + store.set(commandMenuItemsDraftState.atom, persistedCommandMenuItems); + store.set(activeCustomizationPageLayoutIdsState.atom, []); store.set(isLayoutCustomizationModeEnabledState.atom, true); - }, [store]); + + const isSidePanelOpened = store.get(isSidePanelOpenedState.atom); + const currentSidePanelPage = store.get(sidePanelPageState.atom); + + if ( + isCommandMenuItemEnabled && + isSidePanelOpened && + currentSidePanelPage === SidePanelPages.CommandMenuDisplay + ) { + store.set(commandMenuItemEditSelectionModeState.atom, 'selection'); + + navigateSidePanel({ + page: SidePanelPages.CommandMenuEdit, + pageTitle: t`Edit actions`, + pageIcon: IconPencil, + resetNavigationStack: true, + }); + } + }, [isCommandMenuItemEnabled, navigateSidePanel, store]); return { enterLayoutCustomizationMode }; }; diff --git a/packages/twenty-front/src/modules/layout-customization/hooks/useExitLayoutCustomizationMode.ts b/packages/twenty-front/src/modules/layout-customization/hooks/useExitLayoutCustomizationMode.ts index 8700c501d4..1b641e5c01 100644 --- a/packages/twenty-front/src/modules/layout-customization/hooks/useExitLayoutCustomizationMode.ts +++ b/packages/twenty-front/src/modules/layout-customization/hooks/useExitLayoutCustomizationMode.ts @@ -1,3 +1,4 @@ +import { commandMenuItemsDraftState } from '@/command-menu-item/server-items/edit/states/commandMenuItemsDraftState'; import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState'; import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState'; import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState'; @@ -25,6 +26,7 @@ export const useExitLayoutCustomizationMode = () => { const exitLayoutCustomizationMode = useCallback(() => { setNavigationMenuItemsDraft(null); setSelectedNavigationMenuItemIdInEditMode(null); + store.set(commandMenuItemsDraftState.atom, null); store.set(currentPageLayoutIdState.atom, null); store.set(activeCustomizationPageLayoutIdsState.atom, []); diff --git a/packages/twenty-front/src/modules/layout-customization/hooks/useIsLayoutCustomizationDirty.ts b/packages/twenty-front/src/modules/layout-customization/hooks/useIsLayoutCustomizationDirty.ts index 82645a7799..f8c9abd8fd 100644 --- a/packages/twenty-front/src/modules/layout-customization/hooks/useIsLayoutCustomizationDirty.ts +++ b/packages/twenty-front/src/modules/layout-customization/hooks/useIsLayoutCustomizationDirty.ts @@ -1,3 +1,4 @@ +import { useCommandMenuItemsDraftState } from '@/command-menu-item/server-items/common/hooks/useCommandMenuItemsDraftState'; import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState'; import { type DraftPageLayout } from '@/page-layout/types/DraftPageLayout'; import { useNavigationMenuItemsDraftState } from '@/navigation-menu-item/edit/hooks/useNavigationMenuItemsDraftState'; @@ -14,6 +15,7 @@ import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; export const useIsLayoutCustomizationDirty = () => { const { isDirty: isNavigationDirty } = useNavigationMenuItemsDraftState(); + const { isDirty: isCommandMenuItemsDirty } = useCommandMenuItemsDraftState(); const isAnyPageLayoutDirtyAtom = useMemo( () => @@ -93,5 +95,8 @@ export const useIsLayoutCustomizationDirty = () => { const isAnyPageLayoutDirty = useAtomValue(isAnyPageLayoutDirtyAtom); - return { isDirty: isNavigationDirty || isAnyPageLayoutDirty }; + return { + isDirty: + isNavigationDirty || isAnyPageLayoutDirty || isCommandMenuItemsDirty, + }; }; diff --git a/packages/twenty-front/src/modules/layout-customization/hooks/useSaveLayoutCustomization.ts b/packages/twenty-front/src/modules/layout-customization/hooks/useSaveLayoutCustomization.ts index 7ba51eced0..7eb705ccd4 100644 --- a/packages/twenty-front/src/modules/layout-customization/hooks/useSaveLayoutCustomization.ts +++ b/packages/twenty-front/src/modules/layout-customization/hooks/useSaveLayoutCustomization.ts @@ -1,3 +1,5 @@ +import { useSaveCommandMenuItemsDraft } from '@/command-menu-item/server-items/edit/hooks/useSaveCommandMenuItemsDraft'; +import { useCommandMenuItemsDraftState } from '@/command-menu-item/server-items/common/hooks/useCommandMenuItemsDraftState'; import { useExitLayoutCustomizationMode } from '@/layout-customization/hooks/useExitLayoutCustomizationMode'; import { activeCustomizationPageLayoutIdsState } from '@/layout-customization/states/activeCustomizationPageLayoutIdsState'; import { navigationMenuItemsDraftState } from '@/navigation-menu-item/common/states/navigationMenuItemsDraftState'; @@ -31,6 +33,8 @@ export const useSaveLayoutCustomization = () => { const { t } = useLingui(); const { saveDraft } = useSaveNavigationMenuItemsDraft(); + const { saveCommandMenuItemsDraft } = useSaveCommandMenuItemsDraft(); + const { isDirty: isCommandMenuItemsDirty } = useCommandMenuItemsDraftState(); const { enqueueErrorSnackBar } = useSnackBar(); const { updatePageLayoutWithTabsAndWidgets } = useUpdatePageLayoutWithTabsAndWidgets(); @@ -59,6 +63,10 @@ export const useSaveLayoutCustomization = () => { await saveDraft(); } + if (isCommandMenuItemsDirty) { + await saveCommandMenuItemsDraft(); + } + const activePageLayoutIds = store.get( activeCustomizationPageLayoutIdsState.atom, ); @@ -164,6 +172,8 @@ export const useSaveLayoutCustomization = () => { } }, [ saveDraft, + saveCommandMenuItemsDraft, + isCommandMenuItemsDirty, updatePageLayoutWithTabsAndWidgets, savePageLayoutWidgetsData, exitLayoutCustomizationMode, diff --git a/packages/twenty-front/src/modules/metadata-store/hooks/useLoadStaleMetadataEntities.ts b/packages/twenty-front/src/modules/metadata-store/hooks/useLoadStaleMetadataEntities.ts index badcf357b8..85da24bb04 100644 --- a/packages/twenty-front/src/modules/metadata-store/hooks/useLoadStaleMetadataEntities.ts +++ b/packages/twenty-front/src/modules/metadata-store/hooks/useLoadStaleMetadataEntities.ts @@ -12,6 +12,7 @@ import { useCallback } from 'react'; import { isDefined } from 'twenty-shared/utils'; import { FindAllViewsDocument, + FindManyCommandMenuItemsDocument, FindAllRecordPageLayoutsDocument, FindFieldsWidgetViewsDocument, FindManyLogicFunctionsDocument, @@ -193,6 +194,23 @@ export const useLoadStaleMetadataEntities = () => { ); } + if (staleEntityKeys.includes('commandMenuItems')) { + fetchPromises.push( + client + .query({ + query: FindManyCommandMenuItemsDocument, + fetchPolicy: 'network-only', + }) + .then((result) => { + if (!isDefined(result.data?.commandMenuItems)) { + return; + } + + replaceDraft('commandMenuItems', result.data.commandMenuItems); + }), + ); + } + await Promise.all(fetchPromises); applyChanges(); }, diff --git a/packages/twenty-front/src/modules/metadata-store/types/FlatCommandMenuItem.ts b/packages/twenty-front/src/modules/metadata-store/types/FlatCommandMenuItem.ts index 72040d239d..15222e9021 100644 --- a/packages/twenty-front/src/modules/metadata-store/types/FlatCommandMenuItem.ts +++ b/packages/twenty-front/src/modules/metadata-store/types/FlatCommandMenuItem.ts @@ -1,3 +1,3 @@ -import { type CommandMenuItem } from '~/generated-metadata/graphql'; +import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql'; -export type FlatCommandMenuItem = Omit; +export type FlatCommandMenuItem = CommandMenuItemFieldsFragment; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx index 0e7d919c8b..0b3b052e1e 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx @@ -1,17 +1,22 @@ import { RecordIndexCommandMenu } from '@/command-menu-item/components/RecordIndexCommandMenu'; +import { CommandMenuItemMoreActionsButton } from '@/command-menu-item/server-items/display/components/CommandMenuItemMoreActionsButton'; import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId'; import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; +import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { RecordIndexPageHeaderIcon } from '@/object-record/record-index/components/RecordIndexPageHeaderIcon'; import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; import { PageHeaderToggleSidePanelButton } from '@/ui/layout/page-header/components/PageHeaderToggleSidePanelButton'; import { PageHeader } from '@/ui/layout/page/components/PageHeader'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { styled } from '@linaria/react'; import { t } from '@lingui/core/macro'; import { isDefined } from 'twenty-shared/utils'; import { themeCssVariables } from 'twenty-ui/theme-constants'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; const StyledTitleWithSelectedRecords = styled.div` display: flex; @@ -61,6 +66,12 @@ export const RecordIndexPageHeader = () => { contextStoreCurrentViewIdComponentState, MAIN_CONTEXT_STORE_INSTANCE_ID, ); + const isLayoutCustomizationModeEnabled = useAtomStateValue( + isLayoutCustomizationModeEnabledState, + ); + const isCommandMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + ); return ( { {isDefined(contextStoreCurrentViewId) && ( <> - + {isCommandMenuItemEnabled ? ( + !isLayoutCustomizationModeEnabled && ( + + ) + ) : ( + + )} )} diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useEditPageLayoutWidget.ts b/packages/twenty-front/src/modules/page-layout/hooks/useEditPageLayoutWidget.ts index 5b8e49e65a..baf0063e2f 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useEditPageLayoutWidget.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useEditPageLayoutWidget.ts @@ -76,7 +76,7 @@ export const useEditPageLayoutWidget = (pageLayoutIdFromProps?: string) => { return; } - setSidePanelPage(SidePanelPages.Root); + setSidePanelPage(SidePanelPages.CommandMenuDisplay); closeSidePanelMenu(); }, [ diff --git a/packages/twenty-front/src/modules/side-panel/components/SidePanelTopBar.tsx b/packages/twenty-front/src/modules/side-panel/components/SidePanelTopBar.tsx index fe2354c2ec..31edfee161 100644 --- a/packages/twenty-front/src/modules/side-panel/components/SidePanelTopBar.tsx +++ b/packages/twenty-front/src/modules/side-panel/components/SidePanelTopBar.tsx @@ -164,11 +164,13 @@ export const SidePanelTopBar = () => { )} {lastChip && - sidePanelPage !== SidePanelPages.Root && + sidePanelPage !== SidePanelPages.CommandMenuDisplay && + sidePanelPage !== SidePanelPages.CommandMenuEdit && sidePanelPage !== SidePanelPages.SearchRecords && ( )} - {(sidePanelPage === SidePanelPages.Root || + {(sidePanelPage === SidePanelPages.CommandMenuDisplay || + sidePanelPage === SidePanelPages.CommandMenuEdit || sidePanelPage === SidePanelPages.SearchRecords) && ( <> { if ( - sidePanelPage === SidePanelPages.Root || + sidePanelPage === SidePanelPages.CommandMenuDisplay || sidePanelPage === SidePanelPages.SearchRecords ) { inputRef.current?.focus(); diff --git a/packages/twenty-front/src/modules/side-panel/constants/SidePanelPagesConfig.tsx b/packages/twenty-front/src/modules/side-panel/constants/SidePanelPagesConfig.tsx index 6501200565..362935f404 100644 --- a/packages/twenty-front/src/modules/side-panel/constants/SidePanelPagesConfig.tsx +++ b/packages/twenty-front/src/modules/side-panel/constants/SidePanelPagesConfig.tsx @@ -1,3 +1,5 @@ +import { SidePanelCommandMenuItemEditPage } from '@/command-menu-item/server-items/edit/components/SidePanelCommandMenuItemEditPage'; +import { SidePanelCommandMenuItemDisplayPage } from '@/command-menu-item/server-items/display/components/SidePanelCommandMenuItemDisplayPage'; import { SidePanelAIChatThreadsPage } from '@/side-panel/pages/ai-chat-threads/components/SidePanelAIChatThreadsPage'; import { SidePanelAskAIPage } from '@/side-panel/pages/ask-ai/components/SidePanelAskAIPage'; import { SidePanelCalendarEventPage } from '@/side-panel/pages/calendar-event/components/SidePanelCalendarEventPage'; @@ -23,11 +25,28 @@ import { SidePanelWorkflowEditStepType } from '@/side-panel/pages/workflow/step/ import { SidePanelWorkflowRunViewStep } from '@/side-panel/pages/workflow/step/view-run/components/SidePanelWorkflowRunViewStep'; import { SidePanelWorkflowViewStep } from '@/side-panel/pages/workflow/step/view/components/SidePanelWorkflowViewStep'; import { SidePanelWorkflowSelectTriggerType } from '@/side-panel/pages/workflow/trigger-type/components/SidePanelWorkflowSelectTriggerType'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { SidePanelPages } from 'twenty-shared/types'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; + +const SidePanelCommandMenuDisplayPageSwitch = () => { + const isCommandMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + ); + + if (isCommandMenuItemEnabled) { + return ; + } + + return ; +}; export const SIDE_PANEL_PAGES_CONFIG = new Map( [ - [SidePanelPages.Root, ], + [ + SidePanelPages.CommandMenuDisplay, + , + ], [SidePanelPages.ViewRecord, ], [SidePanelPages.MergeRecords, ], [SidePanelPages.UpdateRecords, ], @@ -73,5 +92,6 @@ export const SIDE_PANEL_PAGES_CONFIG = new Map( , ], [SidePanelPages.NavigationMenuAddItem, ], + [SidePanelPages.CommandMenuEdit, ], ], ); diff --git a/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useNavigateSidePanel.test.tsx b/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useNavigateSidePanel.test.tsx index 5844862695..1fc328bb97 100644 --- a/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useNavigateSidePanel.test.tsx +++ b/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useNavigateSidePanel.test.tsx @@ -65,8 +65,8 @@ describe('useNavigateSidePanel', () => { act(() => { result.current.navigateSidePanel({ - page: SidePanelPages.Root, - pageTitle: 'Root', + page: SidePanelPages.CommandMenuDisplay, + pageTitle: 'Command menu', pageIcon: Icon123, pageIconColor: 'red', pageId: 'mocked-uuid', @@ -74,18 +74,20 @@ describe('useNavigateSidePanel', () => { }); }); - expect(jotaiStore.get(sidePanelPageState.atom)).toBe(SidePanelPages.Root); + expect(jotaiStore.get(sidePanelPageState.atom)).toBe( + SidePanelPages.CommandMenuDisplay, + ); expect(jotaiStore.get(sidePanelNavigationStackState.atom)).toEqual([ { - page: SidePanelPages.Root, - pageTitle: 'Root', + page: SidePanelPages.CommandMenuDisplay, + pageTitle: 'Command menu', pageIcon: Icon123, pageIconColor: 'red', pageId: 'mocked-uuid', }, ]); expect(jotaiStore.get(sidePanelPageInfoState.atom)).toEqual({ - title: 'Root', + title: 'Command menu', Icon: Icon123, instanceId: 'mocked-uuid', }); diff --git a/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useSidePanelCloseAnimationCompleteCleanup.test.tsx b/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useSidePanelCloseAnimationCompleteCleanup.test.tsx index 5988a8d976..6631814f70 100644 --- a/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useSidePanelCloseAnimationCompleteCleanup.test.tsx +++ b/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useSidePanelCloseAnimationCompleteCleanup.test.tsx @@ -136,7 +136,9 @@ describe('useSidePanelCloseAnimationCompleteCleanup', () => { result.current.sidePanelCloseAnimationCompleteCleanup(); }); - expect(jotaiStore.get(sidePanelPageState.atom)).toBe(SidePanelPages.Root); + expect(jotaiStore.get(sidePanelPageState.atom)).toBe( + SidePanelPages.CommandMenuDisplay, + ); expect(jotaiStore.get(sidePanelPageInfoState.atom)).toEqual({ title: undefined, Icon: undefined, diff --git a/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useSidePanelHistory.test.tsx b/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useSidePanelHistory.test.tsx index 8f885ef79c..2a1e7238c3 100644 --- a/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useSidePanelHistory.test.tsx +++ b/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useSidePanelHistory.test.tsx @@ -104,7 +104,9 @@ describe('useSidePanelHistory', () => { }); expect(jotaiStore.get(sidePanelNavigationStackState.atom)).toEqual([]); - expect(jotaiStore.get(sidePanelPageState.atom)).toBe(SidePanelPages.Root); + expect(jotaiStore.get(sidePanelPageState.atom)).toBe( + SidePanelPages.CommandMenuDisplay, + ); expect(jotaiStore.get(sidePanelPageInfoState.atom)).toEqual({ title: undefined, instanceId: '', diff --git a/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useUpdateSidePanelPageInfo.test.tsx b/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useUpdateSidePanelPageInfo.test.tsx index 969539d242..ecb7d64934 100644 --- a/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useUpdateSidePanelPageInfo.test.tsx +++ b/packages/twenty-front/src/modules/side-panel/hooks/__tests__/useUpdateSidePanelPageInfo.test.tsx @@ -16,7 +16,7 @@ const mockedPageInfo = { const mockedNavigationStack = [ { - page: SidePanelPages.Root, + page: SidePanelPages.CommandMenuDisplay, pageTitle: 'Initial Title', pageIcon: IconDotsVertical, pageId: 'test-page-id', @@ -65,7 +65,7 @@ describe('useUpdateSidePanelPageInfo', () => { ); expect(sidePanelNavigationStack).toEqual([ { - page: SidePanelPages.Root, + page: SidePanelPages.CommandMenuDisplay, pageTitle: 'New Title', pageIcon: IconArrowDown, pageId: 'test-page-id', @@ -94,7 +94,7 @@ describe('useUpdateSidePanelPageInfo', () => { ); expect(sidePanelNavigationStack).toEqual([ { - page: SidePanelPages.Root, + page: SidePanelPages.CommandMenuDisplay, pageTitle: 'New Title', pageIcon: IconDotsVertical, pageId: 'test-page-id', @@ -123,7 +123,7 @@ describe('useUpdateSidePanelPageInfo', () => { ); expect(sidePanelNavigationStack).toEqual([ { - page: SidePanelPages.Root, + page: SidePanelPages.CommandMenuDisplay, pageTitle: 'Initial Title', pageIcon: IconArrowDown, pageId: 'test-page-id', diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup.ts b/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup.ts index 54ef3aae67..00a4f6393b 100644 --- a/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup.ts +++ b/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelCloseAnimationCompleteCleanup.ts @@ -98,7 +98,7 @@ export const useSidePanelCloseAnimationCompleteCleanup = () => { } store.set(viewableRecordIdState.atom, null); - store.set(sidePanelPageState.atom, SidePanelPages.Root); + store.set(sidePanelPageState.atom, SidePanelPages.CommandMenuDisplay); store.set(sidePanelPageInfoState.atom, { title: undefined, Icon: undefined, diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelContextChips.tsx b/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelContextChips.tsx index 1404e3b870..02d282accf 100644 --- a/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelContextChips.tsx +++ b/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelContextChips.tsx @@ -52,7 +52,7 @@ export const useSidePanelContextChips = () => { const contextChips = useMemo(() => { const filteredSidePanelNavigationStack = sidePanelNavigationStack.filter( - (page) => page.page !== SidePanelPages.Root, + (page) => page.page !== SidePanelPages.CommandMenuDisplay, ); return filteredSidePanelNavigationStack diff --git a/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelMenu.ts b/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelMenu.ts index f2e3bce0b7..ab9964571c 100644 --- a/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelMenu.ts +++ b/packages/twenty-front/src/modules/side-panel/hooks/useSidePanelMenu.ts @@ -67,7 +67,7 @@ export const useSidePanelMenu = () => { }); } else { navigateSidePanel({ - page: SidePanelPages.Root, + page: SidePanelPages.CommandMenuDisplay, pageTitle: t`Command Menu`, pageIcon: IconDotsVertical, resetNavigationStack: true, diff --git a/packages/twenty-front/src/modules/side-panel/states/sidePanelPageState.ts b/packages/twenty-front/src/modules/side-panel/states/sidePanelPageState.ts index 616cc5f089..b3055d4a8b 100644 --- a/packages/twenty-front/src/modules/side-panel/states/sidePanelPageState.ts +++ b/packages/twenty-front/src/modules/side-panel/states/sidePanelPageState.ts @@ -3,5 +3,5 @@ import { SidePanelPages } from 'twenty-shared/types'; export const sidePanelPageState = createAtomState({ key: 'side-panel/sidePanelPageState', - defaultValue: SidePanelPages.Root, + defaultValue: SidePanelPages.CommandMenuDisplay, }); diff --git a/packages/twenty-front/src/modules/ui/layout/page/components/PageHeader.tsx b/packages/twenty-front/src/modules/ui/layout/page/components/PageHeader.tsx index 4a4ea3654f..c1aa744f47 100644 --- a/packages/twenty-front/src/modules/ui/layout/page/components/PageHeader.tsx +++ b/packages/twenty-front/src/modules/ui/layout/page/components/PageHeader.tsx @@ -6,16 +6,16 @@ import { NavigationDrawerCollapseButton } from '@/ui/navigation/navigation-drawe import { PAGE_ACTION_CONTAINER_CLICK_OUTSIDE_ID } from '@/ui/layout/page/constants/PageActionContainerClickOutsideId'; import { PAGE_BAR_MIN_HEIGHT } from '@/ui/layout/page/constants/PageBarMinHeight'; import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded'; -import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { AnimatePresence } from 'framer-motion'; +import { isDefined } from 'twenty-shared/utils'; import { type IconComponent, IconX, OverflowingTextWithTooltip, } from 'twenty-ui/display'; import { LightIconButton } from 'twenty-ui/input'; -import { isDefined } from 'twenty-shared/utils'; import { MOBILE_VIEWPORT, ThemeContext, @@ -34,7 +34,7 @@ const StyledTopBarContainer = styled.div<{ isMobile: boolean }>` min-height: ${PAGE_BAR_MIN_HEIGHT}px; padding-bottom: ${themeCssVariables.spacing[3]}; padding-left: ${({ isMobile }) => - isMobile ? themeCssVariables.spacing[3] : 0}; + isMobile ? themeCssVariables.spacing[3] : themeCssVariables.spacing[4]}; padding-right: ${themeCssVariables.spacing[3]}; padding-top: ${themeCssVariables.spacing[3]}; `; @@ -42,10 +42,11 @@ const StyledTopBarContainer = styled.div<{ isMobile: boolean }>` const StyledLeftContainer = styled.div` align-items: center; display: flex; + flex: 0 1 auto; flex-direction: row; gap: ${themeCssVariables.spacing[1]}; + min-width: 0; overflow-x: hidden; - width: 100%; @media (max-width: ${MOBILE_VIEWPORT}px) { padding-left: ${themeCssVariables.spacing[1]}; } @@ -71,9 +72,13 @@ const StyledTopBarIconStyledTitleContainer = styled.div` `; const StyledPageActionContainer = styled.div` - display: inline-flex; - flex: 1 0 auto; + align-items: center; + display: flex; + flex: 1 1 0; gap: ${themeCssVariables.spacing[2]}; + + justify-content: flex-end; + min-width: 0; `; const StyledIconContainer = styled.div` diff --git a/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx b/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx index 7d9eddef27..2ad447824b 100644 --- a/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx +++ b/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx @@ -1,10 +1,12 @@ import { useParams } from 'react-router-dom'; +import { CommandMenuItemMoreActionsButton } from '@/command-menu-item/server-items/display/components/CommandMenuItemMoreActionsButton'; import { RecordShowCommandMenu } from '@/command-menu-item/components/RecordShowCommandMenu'; import { CommandMenuComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuComponentInstanceContext'; import { TimelineActivityContext } from '@/activities/timeline-activities/contexts/TimelineActivityContext'; import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId'; import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; +import { isLayoutCustomizationModeEnabledState } from '@/layout-customization/states/isLayoutCustomizationModeEnabledState'; import { MainContainerLayoutWithSidePanel } from '@/object-record/components/MainContainerLayoutWithSidePanel'; import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper'; import { PageLayoutRecordPageRenderer } from '@/object-record/record-show/components/PageLayoutRecordPageRenderer'; @@ -13,10 +15,20 @@ import { useRecordShowPage } from '@/object-record/record-show/hooks/useRecordSh import { computeRecordShowComponentInstanceId } from '@/object-record/record-show/utils/computeRecordShowComponentInstanceId'; import { PageHeaderToggleSidePanelButton } from '@/ui/layout/page-header/components/PageHeaderToggleSidePanelButton'; import { PageContainer } from '@/ui/layout/page/components/PageContainer'; +import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { RecordShowPageHeader } from '~/pages/object-record/RecordShowPageHeader'; import { RecordShowPageTitle } from '~/pages/object-record/RecordShowPageTitle'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; export const RecordShowPage = () => { + const isLayoutCustomizationModeEnabled = useAtomStateValue( + isLayoutCustomizationModeEnabledState, + ); + const isCommandMenuItemEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED, + ); + const parameters = useParams<{ objectNameSingular: string; objectRecordId: string; @@ -50,7 +62,13 @@ export const RecordShowPage = () => { objectRecordId={objectRecordId} > - + {isCommandMenuItemEnabled ? ( + !isLayoutCustomizationModeEnabled && ( + + ) + ) : ( + + )} = 2 and objectPermissions.canSoftDeleteObjectRecords and not hasAnySoftDeleteFilterOnView and numberOfSelectedRecords < 10000', @@ -83,10 +83,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { restoreSingleRecord: { universalIdentifier: '8b3a1cae-3e4d-43c1-a71f-48592b2e47ff', label: 'Restore ${capitalize(objectMetadataItem.labelSingular)}', - shortLabel: 'Restore', icon: 'IconRefresh', - position: 5, isPinned: true, + position: 5, + shortLabel: 'Restore', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'numberOfSelectedRecords == 1 and everyDefined(selectedRecords, "deletedAt") and objectPermissions.canSoftDeleteObjectRecords and (pageType == "RECORD_PAGE" or hasAnySoftDeleteFilterOnView)', @@ -98,10 +98,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { restoreMultipleRecords: { universalIdentifier: '8b740c9d-d99a-45a8-812f-809caaf420ac', label: 'Restore ${capitalize(objectMetadataItem.labelPlural)}', - shortLabel: 'Restore', icon: 'IconRefresh', - position: 6, isPinned: true, + position: 6, + shortLabel: 'Restore', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'numberOfSelectedRecords >= 2 and objectPermissions.canSoftDeleteObjectRecords and hasAnySoftDeleteFilterOnView and numberOfSelectedRecords < 10000', @@ -114,10 +114,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { universalIdentifier: '44a78417-c394-4bc8-961f-98b503030ddb', label: 'Permanently destroy ${capitalize(objectMetadataItem.labelSingular)}', - shortLabel: 'Destroy', icon: 'IconTrashX', - position: 7, isPinned: false, + position: 7, + shortLabel: 'Destroy', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'numberOfSelectedRecords == 1 and objectPermissions.canDestroyObjectRecords and everyDefined(selectedRecords, "deletedAt")', @@ -129,10 +129,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { destroyMultipleRecords: { universalIdentifier: 'c630b3fb-7920-40d1-9906-77d0aa797608', label: 'Permanently destroy ${capitalize(objectMetadataItem.labelPlural)}', - shortLabel: 'Destroy', icon: 'IconTrashX', - position: 8, isPinned: false, + position: 8, + shortLabel: 'Destroy', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'numberOfSelectedRecords >= 2 and objectPermissions.canDestroyObjectRecords and hasAnySoftDeleteFilterOnView and numberOfSelectedRecords < 10000', @@ -144,10 +144,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { addToFavorites: { universalIdentifier: '38bf80c3-bd55-4753-80ba-38aa66429a03', label: 'Add to Favorites', - shortLabel: null, icon: 'IconHeart', - position: 9, isPinned: true, + position: 9, + shortLabel: null, availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'arrayLength(favoriteRecordIds) < numberOfSelectedRecords and noneDefined(selectedRecords, "deletedAt") and not hasAnySoftDeleteFilterOnView', @@ -159,10 +159,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { removeFromFavorites: { universalIdentifier: '3ea42507-44fa-4895-a36d-cbfef7355a50', label: 'Remove from Favorites', - shortLabel: null, icon: 'IconHeartOff', - position: 10, isPinned: true, + position: 10, + shortLabel: null, availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'arrayLength(favoriteRecordIds) == numberOfSelectedRecords and noneDefined(selectedRecords, "deletedAt") and not hasAnySoftDeleteFilterOnView', @@ -174,10 +174,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { exportNoteToPdf: { universalIdentifier: '86c8f3aa-9276-4c16-8cff-e295e34fbaf0', label: 'Export to PDF', - shortLabel: 'Export', icon: 'IconFileExport', - position: 11, isPinned: false, + position: 11, + shortLabel: 'Export', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'pageType == "RECORD_PAGE" and (objectMetadataItem.nameSingular == "note" or objectMetadataItem.nameSingular == "task") and someNonEmptyString(selectedRecords, "bodyV2.blocknote")', @@ -189,10 +189,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { exportFromRecordIndex: { universalIdentifier: 'a934ba8a-ac8f-487d-9cd9-06dfdaec1f49', label: 'Export', - shortLabel: 'Export', icon: 'IconFileExport', - position: 12, isPinned: false, + position: 12, + shortLabel: 'Export', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'pageType == "INDEX_PAGE"', availabilityObjectMetadataUniversalIdentifier: null, @@ -203,10 +203,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { exportFromRecordShow: { universalIdentifier: 'ba339455-f3c2-4ed1-bf77-3e316d7d6a66', label: 'Export', - shortLabel: 'Export', icon: 'IconFileExport', - position: 13, isPinned: false, + position: 13, + shortLabel: 'Export', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'pageType == "RECORD_PAGE"', availabilityObjectMetadataUniversalIdentifier: null, @@ -217,10 +217,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { updateMultipleRecords: { universalIdentifier: '2e080651-f098-4a78-bea9-7a70002dc57c', label: 'Update ${capitalize(objectMetadataItem.labelPlural)}', - shortLabel: 'Update', icon: 'IconEdit', - position: 14, isPinned: true, + position: 14, + shortLabel: 'Update', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'numberOfSelectedRecords >= 2 and objectPermissions.canUpdateObjectRecords', @@ -232,10 +232,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { mergeMultipleRecords: { universalIdentifier: '6c14eb04-8e7e-4d47-93c0-8ec4834e2e60', label: 'Merge ${capitalize(objectMetadataItem.labelPlural)}', - shortLabel: 'Merge', icon: 'IconArrowMerge', - position: 15, isPinned: false, + position: 15, + shortLabel: 'Merge', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'numberOfSelectedRecords >= 2 and isDefined(objectMetadataItem.duplicateCriteria) and objectPermissions.canUpdateObjectRecords and objectPermissions.canDestroyObjectRecords and numberOfSelectedRecords <= 9', @@ -247,10 +247,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { exportMultipleRecords: { universalIdentifier: 'f71f68e5-7b6e-4c03-8161-c48434d7777c', label: 'Export ${capitalize(objectMetadataItem.labelPlural)}', - shortLabel: 'Export', icon: 'IconFileExport', - position: 16, isPinned: false, + position: 16, + shortLabel: 'Export', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'numberOfSelectedRecords >= 2', availabilityObjectMetadataUniversalIdentifier: null, @@ -261,10 +261,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { importRecords: { universalIdentifier: 'a2dc9de7-4798-422e-bb55-bfad7b9bdbe8', label: 'Import ${capitalize(objectMetadataItem.labelPlural)}', - shortLabel: 'Import', icon: 'IconFileImport', - position: 17, isPinned: false, + position: 17, + shortLabel: 'Import', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'not hasAnySoftDeleteFilterOnView', availabilityObjectMetadataUniversalIdentifier: null, @@ -275,10 +275,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { exportView: { universalIdentifier: '80680f2a-c426-48b3-a839-c63a6183dc4b', label: 'Export View', - shortLabel: 'Export', icon: 'IconFileExport', - position: 18, isPinned: false, + position: 18, + shortLabel: 'Export', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: null, availabilityObjectMetadataUniversalIdentifier: null, @@ -289,10 +289,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { seeDeletedRecords: { universalIdentifier: 'd63c21c3-9785-4750-be87-5f36269b8e0d', label: 'See deleted ${capitalize(objectMetadataItem.labelPlural)}', - shortLabel: 'Deleted ${capitalize(objectMetadataItem.labelPlural)}', icon: 'IconRotate2', - position: 19, isPinned: false, + position: 19, + shortLabel: 'Deleted ${capitalize(objectMetadataItem.labelPlural)}', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'not hasAnySoftDeleteFilterOnView', availabilityObjectMetadataUniversalIdentifier: null, @@ -303,10 +303,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { createNewView: { universalIdentifier: '6ec7c339-e167-431d-bec6-d1c737df677c', label: 'Create View', - shortLabel: 'Create View', icon: 'IconLayout', - position: 20, isPinned: false, + position: 20, + shortLabel: 'Create View', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'not hasAnySoftDeleteFilterOnView', availabilityObjectMetadataUniversalIdentifier: null, @@ -317,10 +317,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { hideDeletedRecords: { universalIdentifier: '1420db7f-0fba-49e2-b23e-4b7caa0fafa0', label: 'Hide deleted ${capitalize(objectMetadataItem.labelPlural)}', - shortLabel: 'Hide deleted', icon: 'IconEyeOff', - position: 21, isPinned: false, + position: 21, + shortLabel: 'Hide deleted', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'hasAnySoftDeleteFilterOnView', availabilityObjectMetadataUniversalIdentifier: null, @@ -331,10 +331,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { goToPeople: { universalIdentifier: 'dfe5fef8-d42c-40f0-941f-8e3b5eb01daa', label: 'Go to People', - shortLabel: 'People', icon: 'IconUser', - position: 23, isPinned: false, + position: 23, + shortLabel: 'People', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'targetObjectReadPermissions.person', availabilityObjectMetadataUniversalIdentifier: null, @@ -345,10 +345,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { goToCompanies: { universalIdentifier: '196e4eec-bfdd-48a6-bcbb-6707ef11951a', label: 'Go to Companies', - shortLabel: 'Companies', icon: 'IconBuildingSkyscraper', - position: 24, isPinned: false, + position: 24, + shortLabel: 'Companies', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'targetObjectReadPermissions.company', availabilityObjectMetadataUniversalIdentifier: null, @@ -359,10 +359,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { goToDashboards: { universalIdentifier: '11dc07d1-21b2-4f86-af8c-6a664c02f00c', label: 'Go to Dashboards', - shortLabel: 'Dashboards', icon: 'IconLayoutDashboard', - position: 25, isPinned: false, + position: 25, + shortLabel: 'Dashboards', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'targetObjectReadPermissions.dashboard', availabilityObjectMetadataUniversalIdentifier: null, @@ -373,10 +373,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { goToOpportunities: { universalIdentifier: 'f04f5e00-a208-422f-acf2-ff189769510d', label: 'Go to Opportunities', - shortLabel: 'Opportunities', icon: 'IconTargetArrow', - position: 26, isPinned: false, + position: 26, + shortLabel: 'Opportunities', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'targetObjectReadPermissions.opportunity', @@ -388,10 +388,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { goToSettings: { universalIdentifier: 'ef9aba44-0068-453e-930a-f8c182af18ee', label: 'Go to Settings', - shortLabel: 'Settings', icon: 'IconSettings', - position: 27, isPinned: false, + position: 27, + shortLabel: 'Settings', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: null, availabilityObjectMetadataUniversalIdentifier: null, @@ -402,10 +402,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { goToTasks: { universalIdentifier: 'e8e3bd0b-5ce9-4577-bb61-588c0b1ad063', label: 'Go to Tasks', - shortLabel: 'Tasks', icon: 'IconCheckbox', - position: 28, isPinned: false, + position: 28, + shortLabel: 'Tasks', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'targetObjectReadPermissions.task', availabilityObjectMetadataUniversalIdentifier: null, @@ -416,10 +416,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { goToNotes: { universalIdentifier: '08e0f0cc-ac2d-46cd-9bca-39a409b9addf', label: 'Go to Notes', - shortLabel: 'Notes', icon: 'IconCheckbox', - position: 29, isPinned: false, + position: 29, + shortLabel: 'Notes', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'targetObjectReadPermissions.note', availabilityObjectMetadataUniversalIdentifier: null, @@ -430,10 +430,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { editRecordPageLayout: { universalIdentifier: 'd9794c67-1799-424f-8871-5ea771dd4a6d', label: 'Edit Page Layout', - shortLabel: 'Edit Layout', icon: 'IconPencil', - position: 30, isPinned: false, + position: 30, + 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"', @@ -445,10 +445,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { editDashboardLayout: { universalIdentifier: 'b9b53bbc-3129-4eb9-8344-c3f9628ffa7d', label: 'Edit Dashboard', - shortLabel: 'Edit', icon: 'IconPencil', - position: 33, isPinned: true, + position: 33, + shortLabel: 'Edit', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'pageType == "RECORD_PAGE" and not isPageInEditMode and noneDefined(selectedRecords, "deletedAt") and everyDefined(selectedRecords, "pageLayoutId") and objectPermissions.canUpdateObjectRecords', @@ -461,10 +461,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { saveDashboardLayout: { universalIdentifier: '18b23908-f816-42ab-bc0a-eb5fae29c695', label: 'Save Dashboard', - shortLabel: 'Save', icon: 'IconDeviceFloppy', - position: 34, isPinned: true, + position: 34, + shortLabel: 'Save', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'pageType == "RECORD_PAGE" and isPageInEditMode and noneDefined(selectedRecords, "deletedAt") and everyDefined(selectedRecords, "pageLayoutId") and objectPermissions.canUpdateObjectRecords', @@ -477,10 +477,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { cancelDashboardLayout: { universalIdentifier: '030ecd01-0aaf-4e6d-8400-105996548887', label: 'Cancel Edition', - shortLabel: 'Cancel', icon: 'IconCancel', - position: 35, isPinned: true, + position: 35, + shortLabel: 'Cancel', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'pageType == "RECORD_PAGE" and isPageInEditMode and noneDefined(selectedRecords, "deletedAt") and everyDefined(selectedRecords, "pageLayoutId") and objectPermissions.canUpdateObjectRecords', @@ -493,10 +493,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { duplicateDashboard: { universalIdentifier: '2ee07307-60ce-41ef-bfee-7c718f67557e', label: 'Duplicate Dashboard', - shortLabel: 'Duplicate', icon: 'IconCopyPlus', - position: 36, isPinned: false, + position: 36, + shortLabel: 'Duplicate', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'noneDefined(selectedRecords, "deletedAt") and objectPermissions.canUpdateObjectRecords', @@ -509,10 +509,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { goToWorkflows: { universalIdentifier: '4fa778f9-7931-4d18-b895-929e1ef9c31f', label: 'Go to Workflows', - shortLabel: 'Workflows', icon: 'IconSettingsAutomation', - position: 41, isPinned: false, + position: 41, + shortLabel: 'Workflows', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'targetObjectReadPermissions.workflow', availabilityObjectMetadataUniversalIdentifier: null, @@ -523,10 +523,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { activateWorkflow: { universalIdentifier: '44f19c85-0fd0-482f-a14e-da513c60b1b3', label: 'Activate Workflow', - shortLabel: 'Activate', icon: 'IconPower', - position: 42, isPinned: true, + position: 42, + shortLabel: 'Activate', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'everyDefined(selectedRecords, "currentVersion.trigger") and everyDefined(selectedRecords, "currentVersion.steps") and every(selectedRecords, "currentVersion.steps.length") and (everyEquals(selectedRecords, "currentVersion.status", "DRAFT") or includesNone(selectedRecords, "statuses", "ACTIVE")) and noneDefined(selectedRecords, "deletedAt")', @@ -539,10 +539,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { deactivateWorkflow: { universalIdentifier: '57f21a06-a17a-47b1-a123-90d90dbdf0b7', label: 'Deactivate Workflow', - shortLabel: 'Deactivate', icon: 'IconPlayerPause', - position: 43, isPinned: true, + position: 43, + shortLabel: 'Deactivate', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'everyEquals(selectedRecords, "currentVersion.status", "ACTIVE") and noneDefined(selectedRecords, "deletedAt")', @@ -555,10 +555,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { discardDraftWorkflow: { universalIdentifier: '4c227f2e-03bb-4a66-9b13-49f263264f4a', label: 'Discard Draft', - shortLabel: 'Discard Draft', icon: 'IconNoteOff', - position: 44, isPinned: true, + position: 44, + shortLabel: 'Discard Draft', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'every(selectedRecords, "versions.length") and everyEquals(selectedRecords, "currentVersion.status", "DRAFT") and noneDefined(selectedRecords, "deletedAt")', @@ -571,10 +571,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { testWorkflow: { universalIdentifier: 'f85d552a-87a3-4667-99f7-71b47917539c', label: 'Test Workflow', - shortLabel: 'Test', icon: 'IconPlayerPlay', - position: 45, isPinned: true, + position: 45, + shortLabel: 'Test', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'everyDefined(selectedRecords, "currentVersion.trigger") and everyDefined(selectedRecords, "currentVersion.steps") and every(selectedRecords, "currentVersion.steps.length") and ((everyEquals(selectedRecords, "currentVersion.trigger.type", "MANUAL") and noneDefined(selectedRecords, "currentVersion.trigger.settings.objectType")) or everyEquals(selectedRecords, "currentVersion.trigger.type", "WEBHOOK") or everyEquals(selectedRecords, "currentVersion.trigger.type", "CRON")) and noneDefined(selectedRecords, "deletedAt")', @@ -587,10 +587,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { seeActiveVersionWorkflow: { universalIdentifier: '31790508-75ff-4e4c-a768-83bd1b0718e0', label: 'See Active Version', - shortLabel: 'See Active Version', icon: 'IconVersions', - position: 46, isPinned: false, + position: 46, + shortLabel: 'See Active Version', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'includesEvery(selectedRecords, "statuses", "ACTIVE") and includesEvery(selectedRecords, "statuses", "DRAFT") and noneDefined(selectedRecords, "deletedAt")', @@ -603,10 +603,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { seeRunsWorkflow: { universalIdentifier: 'e57efc2d-00a2-493a-b76c-f2dabd23a5eb', label: 'See Runs', - shortLabel: 'See Runs', icon: 'IconHistoryToggle', - position: 47, isPinned: true, + position: 47, + shortLabel: 'See Runs', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'noneDefined(selectedRecords, "deletedAt")', @@ -619,10 +619,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { seeVersionsWorkflow: { universalIdentifier: '92781d24-b875-4282-8cdb-d127f04a5c7d', label: 'See Versions History', - shortLabel: 'See Versions', icon: 'IconVersions', - position: 48, isPinned: false, + position: 48, + shortLabel: 'See Versions', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'noneDefined(selectedRecords, "deletedAt")', @@ -635,10 +635,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { addNodeWorkflow: { universalIdentifier: '818117fa-6cad-4ebc-83c1-40f4afc28d94', label: 'Add a Node', - shortLabel: 'Add a Node', icon: 'IconPlus', - position: 49, isPinned: true, + position: 49, + shortLabel: 'Add a Node', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'pageType == "RECORD_PAGE" and everyDefined(selectedRecords, "currentVersion.trigger") and everyDefined(selectedRecords, "currentVersion.steps") and every(selectedRecords, "currentVersion.steps.length") and noneDefined(selectedRecords, "deletedAt")', @@ -651,10 +651,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { tidyUpWorkflow: { universalIdentifier: '1f3a3cab-161a-4775-af47-11be4d0bf411', label: 'Tidy up Workflow', - shortLabel: 'Tidy up', icon: 'IconReorder', - position: 50, isPinned: false, + position: 50, + shortLabel: 'Tidy up', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'pageType == "RECORD_PAGE" and everyDefined(selectedRecords, "currentVersion.trigger") and everyDefined(selectedRecords, "currentVersion.steps") and every(selectedRecords, "currentVersion.steps.length") and noneDefined(selectedRecords, "deletedAt")', @@ -667,10 +667,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { duplicateWorkflow: { universalIdentifier: '91094438-b4c2-46ad-a23b-8af4b23ba514', label: 'Duplicate Workflow', - shortLabel: 'Duplicate', icon: 'IconCopy', - position: 51, isPinned: false, + position: 51, + shortLabel: 'Duplicate', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'everyDefined(selectedRecords, "currentVersion") and noneDefined(selectedRecords, "deletedAt")', @@ -683,10 +683,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { goToRuns: { universalIdentifier: '1ba959da-ff49-4c1f-a517-2b78ee200508', label: 'Go to Runs', - shortLabel: 'See Runs', icon: 'IconHistoryToggle', - position: 52, isPinned: false, + position: 52, + shortLabel: 'See Runs', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: 'not hasAnySoftDeleteFilterOnView and objectMetadataItem.nameSingular != "workflowRun" and objectMetadataItem.nameSingular != "workflowVersion" and objectMetadataItem.nameSingular != "workflow"', @@ -698,10 +698,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { seeVersionWorkflowRun: { universalIdentifier: 'cc3a065c-c89e-40ac-9449-4272c55b1bb8', label: 'See Version', - shortLabel: 'See Version', icon: 'IconVersions', - position: 53, isPinned: true, + position: 53, + shortLabel: 'See Version', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: null, availabilityObjectMetadataUniversalIdentifier: @@ -713,10 +713,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { seeWorkflowWorkflowRun: { universalIdentifier: '9d9cc62d-3543-45c3-93f3-23d2d8979f2b', label: 'See Workflow', - shortLabel: 'See Workflow', icon: 'IconSettingsAutomation', - position: 54, isPinned: true, + position: 54, + shortLabel: 'See Workflow', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: null, availabilityObjectMetadataUniversalIdentifier: @@ -728,10 +728,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { stopWorkflowRun: { universalIdentifier: '4c186606-9515-4561-a1eb-9a072b4f5e58', label: 'Stop', - shortLabel: 'Stop', icon: 'IconPlayerStop', - position: 55, isPinned: true, + position: 55, + shortLabel: 'Stop', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'isSelectAll or someEquals(selectedRecords, "status", "NOT_STARTED") or someEquals(selectedRecords, "status", "ENQUEUED") or someEquals(selectedRecords, "status", "RUNNING")', @@ -744,10 +744,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { seeRunsWorkflowVersion: { universalIdentifier: '44e305c7-4f0a-45ec-803f-6471b56455cb', label: 'See Runs', - shortLabel: 'See Runs', icon: 'IconHistoryToggle', - position: 56, isPinned: true, + position: 56, + shortLabel: 'See Runs', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'everyDefined(selectedRecords, "workflow")', @@ -760,10 +760,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { seeWorkflowWorkflowVersion: { universalIdentifier: 'b43052db-023e-4083-9b63-2c2dfbfd1320', label: 'See Workflow', - shortLabel: 'See Workflow', icon: 'IconSettingsAutomation', - position: 57, isPinned: true, + position: 57, + shortLabel: 'See Workflow', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'everyDefined(selectedRecords, "workflow.id")', @@ -776,10 +776,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { useAsDraftWorkflowVersion: { universalIdentifier: '483c0c1d-ea4d-4a4d-8a59-2dcf9f8e38f6', label: 'Use as Draft', - shortLabel: 'Use as Draft', icon: 'IconPencil', - position: 58, isPinned: true, + position: 58, + shortLabel: 'Use as Draft', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'noneEquals(selectedRecords, "status", "DRAFT")', @@ -792,10 +792,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { seeVersionsWorkflowVersion: { universalIdentifier: '1d4abeb7-2750-4af7-9a92-fbadd2a9e4ba', label: 'See Versions History', - shortLabel: 'See Versions', icon: 'IconVersions', - position: 59, isPinned: false, + position: 59, + shortLabel: 'See Versions', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: 'everyDefined(selectedRecords, "workflow")', @@ -808,10 +808,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { searchRecords: { universalIdentifier: 'fa24e25e-68f8-4548-82ff-c7b5168b7c7d', label: 'Search ${capitalize(objectMetadataItem.labelPlural)}', - shortLabel: 'Search', icon: 'IconSearch', - position: 60, isPinned: false, + position: 60, + shortLabel: 'Search', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: null, availabilityObjectMetadataUniversalIdentifier: null, @@ -822,10 +822,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { searchRecordsFallback: { universalIdentifier: 'c659890c-7266-46c9-bfe1-75cefff8b6d0', label: 'Search ${capitalize(objectMetadataItem.labelPlural)}', - shortLabel: 'Search', icon: 'IconSearch', - position: 61, isPinned: false, + position: 61, + shortLabel: 'Search', availabilityType: CommandMenuItemAvailabilityType.FALLBACK, conditionalAvailabilityExpression: null, availabilityObjectMetadataUniversalIdentifier: null, @@ -836,10 +836,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { askAi: { universalIdentifier: 'ce5fb54d-2b19-4dd1-b7b4-9532a1761a41', label: 'Ask AI', - shortLabel: 'Ask AI', icon: 'IconSparkles', - position: 62, isPinned: false, + position: 62, + shortLabel: 'Ask AI', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: null, availabilityObjectMetadataUniversalIdentifier: null, @@ -850,10 +850,10 @@ export const STANDARD_COMMAND_MENU_ITEMS = { viewPreviousAiChats: { universalIdentifier: '3084c3c9-cc23-4dad-9e00-92025f5cba7a', label: 'View Previous AI Chats', - shortLabel: 'Previous AI Chats', icon: 'IconHistory', - position: 63, isPinned: false, + position: 63, + shortLabel: 'Previous AI Chats', availabilityType: CommandMenuItemAvailabilityType.GLOBAL, conditionalAvailabilityExpression: null, availabilityObjectMetadataUniversalIdentifier: null, diff --git a/packages/twenty-shared/src/types/SidePanelPages.ts b/packages/twenty-shared/src/types/SidePanelPages.ts index 358c8d784d..abaac379b1 100644 --- a/packages/twenty-shared/src/types/SidePanelPages.ts +++ b/packages/twenty-shared/src/types/SidePanelPages.ts @@ -1,5 +1,5 @@ export enum SidePanelPages { - Root = 'root', + CommandMenuDisplay = 'command-menu-display', ViewRecord = 'view-record', MergeRecords = 'merge-records', UpdateRecords = 'update-records', @@ -25,4 +25,5 @@ export enum SidePanelPages { ViewFrontComponent = 'view-front-component', NavigationMenuItemEdit = 'navigation-menu-item-edit', NavigationMenuAddItem = 'navigation-menu-add-item', + CommandMenuEdit = 'command-menu-edit', } diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 8b29182def..5629fa6ace 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -238,6 +238,7 @@ export { IconLayoutList, IconLayoutNavbar, IconLayoutSidebarLeftCollapse, + IconLayoutSidebarLeftExpand, IconLayoutSidebarRight, IconLayoutSidebarRightCollapse, IconLayoutSidebarRightExpand, @@ -290,6 +291,7 @@ export { IconPhoto, IconPhotoUp, IconPilcrow, + IconPin, IconPinned, IconPinnedOff, IconPlayerPause, @@ -351,6 +353,7 @@ export { IconSpy, IconSql, IconSquare, + IconSquareCheck, IconSquareKey, IconSquareNumber1, IconSquareNumber2, @@ -364,6 +367,7 @@ export { IconSquarePlus, IconSquareRoundedCheck, IconSquareRoundedX, + IconSquareX, IconStack2, IconStatusChange, IconStepInto, diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index fa9335a0a4..6b9a917260 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -316,6 +316,7 @@ export { IconLayoutList, IconLayoutNavbar, IconLayoutSidebarLeftCollapse, + IconLayoutSidebarLeftExpand, IconLayoutSidebarRight, IconLayoutSidebarRightCollapse, IconLayoutSidebarRightExpand, @@ -368,6 +369,7 @@ export { IconPhoto, IconPhotoUp, IconPilcrow, + IconPin, IconPinned, IconPinnedOff, IconPlayerPause, @@ -429,6 +431,7 @@ export { IconSpy, IconSql, IconSquare, + IconSquareCheck, IconSquareKey, IconSquareNumber1, IconSquareNumber2, @@ -442,6 +445,7 @@ export { IconSquarePlus, IconSquareRoundedCheck, IconSquareRoundedX, + IconSquareX, IconStack2, IconStatusChange, IconStepInto, diff --git a/packages/twenty-ui/src/utilities/animation/components/AnimatedIconCrossfade.tsx b/packages/twenty-ui/src/utilities/animation/components/AnimatedIconCrossfade.tsx new file mode 100644 index 0000000000..905790ec63 --- /dev/null +++ b/packages/twenty-ui/src/utilities/animation/components/AnimatedIconCrossfade.tsx @@ -0,0 +1,69 @@ +import { styled } from '@linaria/react'; +import { motion } from 'framer-motion'; +import { useContext } from 'react'; +import { type IconComponent } from '@ui/display'; +import { ThemeContext } from '@ui/theme-constants'; + +const StyledContainer = styled.div<{ size: number }>` + height: ${({ size }) => size}px; + overflow: hidden; + position: relative; + width: ${({ size }) => size}px; +`; + +const StyledLayer = styled(motion.div)` + align-items: center; + display: flex; + inset: 0; + justify-content: center; + position: absolute; +`; + +type AnimatedIconCrossfadeProps = { + isActive: boolean; + ActiveIcon: IconComponent; + InactiveIcon: IconComponent; + size?: number; +}; + +export const AnimatedIconCrossfade = ({ + isActive, + ActiveIcon, + InactiveIcon, + size, +}: AnimatedIconCrossfadeProps) => { + const { theme } = useContext(ThemeContext); + + const iconSize = size ?? theme.icon.size.sm; + + return ( + + + + + + + + + ); +}; diff --git a/packages/twenty-ui/src/utilities/animation/index.ts b/packages/twenty-ui/src/utilities/animation/index.ts index cba279289a..1754312639 100644 --- a/packages/twenty-ui/src/utilities/animation/index.ts +++ b/packages/twenty-ui/src/utilities/animation/index.ts @@ -1,7 +1,8 @@ +export * from './components/AnimatedCircleLoading'; export * from './components/AnimatedContainer'; export * from './components/AnimatedEaseIn'; export * from './components/AnimatedEaseInOut'; export * from './components/AnimatedFadeOut'; +export * from './components/AnimatedIconCrossfade'; export * from './components/AnimatedTextWord'; export * from './components/AnimatedTranslation'; -export * from './components/AnimatedCircleLoading'; diff --git a/packages/twenty-ui/src/utilities/index.ts b/packages/twenty-ui/src/utilities/index.ts index 1c5549a4a5..71c09cc407 100644 --- a/packages/twenty-ui/src/utilities/index.ts +++ b/packages/twenty-ui/src/utilities/index.ts @@ -12,6 +12,7 @@ export { AnimatedContainer } from './animation/components/AnimatedContainer'; export { AnimatedEaseIn } from './animation/components/AnimatedEaseIn'; export { AnimatedEaseInOut } from './animation/components/AnimatedEaseInOut'; export { AnimatedFadeOut } from './animation/components/AnimatedFadeOut'; +export { AnimatedIconCrossfade } from './animation/components/AnimatedIconCrossfade'; export { AnimatedRotate } from './animation/components/AnimatedRotate'; export { AnimatedTextWord } from './animation/components/AnimatedTextWord'; export { AnimatedTranslation } from './animation/components/AnimatedTranslation';