9426 migrate workflow pages to command menu (#9515)

Closes twentyhq/core-team-issues#53 

- Removes command menu top bar text input when the user is not on root
page
- Fixes bug when resetting command menu context
- Added animations on command menu open and close
- Refactored workflow visualizer code to remove unnecessary rerenders
and props drilling


https://github.com/user-attachments/assets/1da3adb8-220b-407b-9279-30354d3100d3
This commit is contained in:
Raphaël Bosi
2025-01-13 16:53:57 +01:00
committed by GitHub
parent 330addbc0b
commit 530a18558b
22 changed files with 328 additions and 168 deletions

View File

@ -3,20 +3,25 @@ import { RecordAgnosticActionsSetterEffect } from '@/action-menu/actions/record-
import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { COMMAND_MENU_ANIMATION_VARIANTS } from '@/command-menu/constants/CommandMenuAnimationVariants';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { CommandMenuAnimationVariant } from '@/command-menu/types/CommandMenuAnimationVariant';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { workflowReactFlowRefState } from '@/workflow/workflow-diagram/states/workflowReactFlowRefState';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { useIsMobile } from 'twenty-ui';
import { FeatureFlagKey } from '~/generated/graphql';
const StyledCommandMenu = styled.div`
const StyledCommandMenu = styled(motion.div)`
background: ${({ theme }) => theme.background.secondary};
border-left: 1px solid ${({ theme }) => theme.border.color.medium};
box-shadow: ${({ theme }) => theme.boxShadow.strong};
@ -27,8 +32,9 @@ const StyledCommandMenu = styled.div`
position: fixed;
right: 0%;
top: 0%;
width: ${() => (useIsMobile() ? '100%' : '500px')};
z-index: 30;
display: flex;
flex-direction: column;
`;
export const CommandMenuContainer = ({
@ -45,15 +51,28 @@ export const CommandMenuContainer = ({
const commandMenuRef = useRef<HTMLDivElement>(null);
const workflowReactFlowRef = useRecoilValue(workflowReactFlowRefState);
useCommandMenuHotKeys();
useListenClickOutside({
refs: [commandMenuRef],
refs: [
commandMenuRef,
...(workflowReactFlowRef ? [workflowReactFlowRef] : []),
],
callback: closeCommandMenu,
listenerId: 'COMMAND_MENU_LISTENER_ID',
hotkeyScope: AppHotkeyScope.CommandMenuOpen,
});
const isMobile = useIsMobile();
const targetVariantForAnimation: CommandMenuAnimationVariant = isMobile
? 'fullScreen'
: 'normal';
const theme = useTheme();
return (
<ContextStoreComponentInstanceContext.Provider
value={{ instanceId: 'command-menu' }}
@ -71,7 +90,17 @@ export const CommandMenuContainer = ({
{isWorkflowEnabled && <RecordAgnosticActionsSetterEffect />}
<ActionMenuConfirmationModals />
{isCommandMenuOpened && (
<StyledCommandMenu ref={commandMenuRef} className="command-menu">
<StyledCommandMenu
ref={commandMenuRef}
className="command-menu"
animate={targetVariantForAnimation}
initial="closed"
exit="closed"
variants={COMMAND_MENU_ANIMATION_VARIANTS}
transition={{
duration: theme.animation.duration.normal,
}}
>
{children}
</StyledCommandMenu>
)}

View File

@ -3,4 +3,8 @@ export enum CommandMenuPages {
ViewRecord = 'view-record',
ViewEmailThread = 'view-email-thread',
ViewCalendarEvent = 'view-calendar-event',
WorkflowStepSelectTriggerType = 'workflow-step-select-trigger-type',
WorkflowStepSelectAction = 'workflow-step-select-action',
WorkflowStepView = 'workflow-step-view',
WorkflowStepEdit = 'workflow-step-edit',
}

View File

@ -2,9 +2,15 @@ import { CommandMenuContainer } from '@/command-menu/components/CommandMenuConta
import { CommandMenuTopBar } from '@/command-menu/components/CommandMenuTopBar';
import { COMMAND_MENU_PAGES_CONFIG } from '@/command-menu/constants/CommandMenuPagesConfig';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
const StyledCommandMenuContent = styled.div`
flex: 1;
overflow-y: auto;
`;
export const CommandMenuRouter = () => {
const commandMenuPage = useRecoilValue(commandMenuPageState);
@ -17,7 +23,9 @@ export const CommandMenuRouter = () => {
return (
<CommandMenuContainer>
<CommandMenuTopBar />
{commandMenuPageComponent}
<StyledCommandMenuContent>
{commandMenuPageComponent}
</StyledCommandMenuContent>
</CommandMenuContainer>
);
};

View File

@ -1,12 +1,14 @@
import { CommandMenuContextRecordChip } from '@/command-menu/components/CommandMenuContextRecordChip';
import { CommandMenuPages } from '@/command-menu/components/CommandMenuPages';
import { COMMAND_MENU_SEARCH_BAR_HEIGHT } from '@/command-menu/constants/CommandMenuSearchBarHeight';
import { COMMAND_MENU_SEARCH_BAR_PADDING } from '@/command-menu/constants/CommandMenuSearchBarPadding';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import styled from '@emotion/styled';
import { useRecoilState } from 'recoil';
import { useRecoilState, useRecoilValue } from 'recoil';
import { IconX, LightIconButton, isDefined, useIsMobile } from 'twenty-ui';
const StyledInputContainer = styled.div`
@ -17,6 +19,7 @@ const StyledInputContainer = styled.div`
border-radius: 0;
display: flex;
justify-content: space-between;
font-size: ${({ theme }) => theme.font.size.lg};
height: ${COMMAND_MENU_SEARCH_BAR_HEIGHT}px;
margin: 0;
@ -25,6 +28,7 @@ const StyledInputContainer = styled.div`
padding: 0 ${({ theme }) => theme.spacing(COMMAND_MENU_SEARCH_BAR_PADDING)};
gap: ${({ theme }) => theme.spacing(1)};
flex-shrink: 0;
`;
const StyledInput = styled.input`
@ -45,6 +49,13 @@ const StyledInput = styled.input`
}
`;
const StyledContentContainer = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledCloseButtonContainer = styled.div`
align-items: center;
display: flex;
@ -69,19 +80,25 @@ export const CommandMenuTopBar = () => {
contextStoreCurrentObjectMetadataIdComponentState,
);
const commandMenuPage = useRecoilValue(commandMenuPageState);
return (
<StyledInputContainer>
{isDefined(contextStoreCurrentObjectMetadataId) && (
<CommandMenuContextRecordChip
objectMetadataItemId={contextStoreCurrentObjectMetadataId}
/>
)}
<StyledInput
autoFocus
value={commandMenuSearch}
placeholder="Type anything"
onChange={handleSearchChange}
/>
<StyledContentContainer>
{isDefined(contextStoreCurrentObjectMetadataId) && (
<CommandMenuContextRecordChip
objectMetadataItemId={contextStoreCurrentObjectMetadataId}
/>
)}
{commandMenuPage === CommandMenuPages.Root && (
<StyledInput
autoFocus
value={commandMenuSearch}
placeholder="Type anything"
onChange={handleSearchChange}
/>
)}
</StyledContentContainer>
{!isMobile && (
<StyledCloseButtonContainer>
<LightIconButton

View File

@ -0,0 +1,25 @@
import { THEME_COMMON } from 'twenty-ui';
export const COMMAND_MENU_ANIMATION_VARIANTS = {
fullScreen: {
x: '0%',
width: '100%',
height: '100%',
bottom: '0',
top: '0',
},
normal: {
x: '0%',
width: THEME_COMMON.rightDrawerWidth,
height: '100%',
bottom: '0',
top: '0',
},
closed: {
x: '100%',
width: THEME_COMMON.rightDrawerWidth,
height: '100%',
bottom: '0',
top: 'auto',
},
};

View File

@ -3,6 +3,10 @@ import { RightDrawerEmailThread } from '@/activities/emails/right-drawer/compone
import { CommandMenu } from '@/command-menu/components/CommandMenu';
import { CommandMenuPages } from '@/command-menu/components/CommandMenuPages';
import { RightDrawerRecord } from '@/object-record/record-right-drawer/components/RightDrawerRecord';
import { RightDrawerWorkflowEditStep } from '@/workflow/workflow-steps/components/RightDrawerWorkflowEditStep';
import { RightDrawerWorkflowViewStep } from '@/workflow/workflow-steps/components/RightDrawerWorkflowViewStep';
import { RightDrawerWorkflowSelectAction } from '@/workflow/workflow-steps/workflow-actions/components/RightDrawerWorkflowSelectAction';
import { RightDrawerWorkflowSelectTriggerType } from '@/workflow/workflow-trigger/components/RightDrawerWorkflowSelectTriggerType';
export const COMMAND_MENU_PAGES_CONFIG = new Map<
CommandMenuPages,
@ -12,4 +16,14 @@ export const COMMAND_MENU_PAGES_CONFIG = new Map<
[CommandMenuPages.ViewRecord, <RightDrawerRecord />],
[CommandMenuPages.ViewEmailThread, <RightDrawerEmailThread />],
[CommandMenuPages.ViewCalendarEvent, <RightDrawerCalendarEvent />],
[
CommandMenuPages.WorkflowStepSelectTriggerType,
<RightDrawerWorkflowSelectTriggerType />,
],
[
CommandMenuPages.WorkflowStepSelectAction,
<RightDrawerWorkflowSelectAction />,
],
[CommandMenuPages.WorkflowStepEdit, <RightDrawerWorkflowEditStep />],
[CommandMenuPages.WorkflowStepView, <RightDrawerWorkflowViewStep />],
]);

View File

@ -1,4 +1,4 @@
import { useRecoilCallback, useRecoilValue, useSetRecoilState } from 'recoil';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
@ -17,10 +17,10 @@ import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-sto
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { mainContextStoreComponentInstanceIdState } from '@/context-store/states/mainContextStoreComponentInstanceId';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { emitRightDrawerCloseEvent } from '@/ui/layout/right-drawer/utils/emitRightDrawerCloseEvent';
import { isCommandMenuOpenedState } from '../states/isCommandMenuOpenedState';
export const useCommandMenu = () => {
const setIsCommandMenuOpened = useSetRecoilState(isCommandMenuOpenedState);
const { resetSelectedItem } = useSelectableList('command-menu-list');
const {
setHotkeyScopeAndMemorizePreviousScope,
@ -141,13 +141,12 @@ export const useCommandMenu = () => {
actionMenuEntries,
);
setIsCommandMenuOpened(true);
set(isCommandMenuOpenedState, true);
setHotkeyScopeAndMemorizePreviousScope(AppHotkeyScope.CommandMenuOpen);
},
[
mainContextStoreComponentInstanceId,
setHotkeyScopeAndMemorizePreviousScope,
setIsCommandMenuOpened,
],
);
@ -158,67 +157,69 @@ export const useCommandMenu = () => {
.getLoadable(isCommandMenuOpenedState)
.getValue();
set(
contextStoreCurrentObjectMetadataIdComponentState.atomFamily({
instanceId: 'command-menu',
}),
null,
);
set(
contextStoreTargetedRecordsRuleComponentState.atomFamily({
instanceId: 'command-menu',
}),
{
mode: 'selection',
selectedRecordIds: [],
},
);
set(
contextStoreNumberOfSelectedRecordsComponentState.atomFamily({
instanceId: 'command-menu',
}),
0,
);
set(
contextStoreFiltersComponentState.atomFamily({
instanceId: 'command-menu',
}),
[],
);
set(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId: 'command-menu',
}),
null,
);
set(
contextStoreCurrentViewTypeComponentState.atomFamily({
instanceId: 'command-menu',
}),
null,
);
set(
actionMenuEntriesComponentState.atomFamily({
instanceId: 'command-menu',
}),
new Map(),
);
if (isCommandMenuOpened) {
set(
contextStoreCurrentObjectMetadataIdComponentState.atomFamily({
instanceId: 'command-menu',
}),
null,
);
set(
contextStoreTargetedRecordsRuleComponentState.atomFamily({
instanceId: 'command-menu',
}),
{
mode: 'selection',
selectedRecordIds: [],
},
);
set(
contextStoreNumberOfSelectedRecordsComponentState.atomFamily({
instanceId: 'command-menu',
}),
0,
);
set(
contextStoreFiltersComponentState.atomFamily({
instanceId: 'command-menu',
}),
[],
);
set(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId: 'command-menu',
}),
null,
);
set(
contextStoreCurrentViewTypeComponentState.atomFamily({
instanceId: 'command-menu',
}),
null,
);
set(
actionMenuEntriesComponentState.atomFamily({
instanceId: 'command-menu',
}),
new Map(),
);
set(viewableRecordIdState, null);
set(commandMenuPageState, CommandMenuPages.Root);
setIsCommandMenuOpened(false);
set(isCommandMenuOpenedState, false);
resetSelectedItem();
goBackToPreviousHotkeyScope();
emitRightDrawerCloseEvent();
}
},
[goBackToPreviousHotkeyScope, resetSelectedItem, setIsCommandMenuOpened],
[goBackToPreviousHotkeyScope, resetSelectedItem],
);
const toggleCommandMenu = useRecoilCallback(
@ -250,10 +251,39 @@ export const useCommandMenu = () => {
[openCommandMenu],
);
const setGlobalCommandMenuContext = useRecoilCallback(({ set }) => {
return () => {
set(
contextStoreTargetedRecordsRuleComponentState.atomFamily({
instanceId: 'command-menu',
}),
{
mode: 'selection',
selectedRecordIds: [],
},
);
set(
contextStoreNumberOfSelectedRecordsComponentState.atomFamily({
instanceId: 'command-menu',
}),
0,
);
set(
contextStoreCurrentViewTypeComponentState.atomFamily({
instanceId: 'command-menu',
}),
null,
);
};
}, []);
return {
openCommandMenu,
closeCommandMenu,
openRecordInCommandMenu,
toggleCommandMenu,
resetCommandMenuContext: setGlobalCommandMenuContext,
};
};

View File

@ -1,32 +1,24 @@
import { CommandMenuPages } from '@/command-menu/components/CommandMenuPages';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useKeyboardShortcutMenu } from '@/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { AppHotkeyScope } from '@/ui/utilities/hotkey/types/AppHotkeyScope';
import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { Key } from 'ts-key-enum';
export const useCommandMenuHotKeys = () => {
const { closeCommandMenu, toggleCommandMenu } = useCommandMenu();
const { closeCommandMenu, toggleCommandMenu, resetCommandMenuContext } =
useCommandMenu();
const commandMenuSearch = useRecoilValue(commandMenuSearchState);
const setContextStoreTargetedRecordsRule = useSetRecoilComponentStateV2(
contextStoreTargetedRecordsRuleComponentState,
'command-menu',
);
const setContextStoreNumberOfSelectedRecords = useSetRecoilComponentStateV2(
contextStoreNumberOfSelectedRecordsComponentState,
'command-menu',
);
const { closeKeyboardShortcutMenu } = useKeyboardShortcutMenu();
const commandMenuPage = useRecoilValue(commandMenuPageState);
useScopedHotkeys(
'ctrl+k,meta+k',
() => {
@ -49,13 +41,11 @@ export const useCommandMenuHotKeys = () => {
useScopedHotkeys(
[Key.Backspace, Key.Delete],
() => {
if (!isNonEmptyString(commandMenuSearch)) {
setContextStoreTargetedRecordsRule({
mode: 'selection',
selectedRecordIds: [],
});
setContextStoreNumberOfSelectedRecords(0);
if (
commandMenuPage === CommandMenuPages.Root &&
!isNonEmptyString(commandMenuSearch)
) {
resetCommandMenuContext();
}
},
AppHotkeyScope.CommandMenuOpen,

View File

@ -0,0 +1,4 @@
import { COMMAND_MENU_ANIMATION_VARIANTS } from '@/command-menu/constants/CommandMenuAnimationVariants';
export type CommandMenuAnimationVariant =
keyof typeof COMMAND_MENU_ANIMATION_VARIANTS;