refactor!: rename Command Menu page/navigation layer to Side Panel (#18393)

This commit is contained in:
nitin
2026-03-05 20:16:31 +05:30
committed by GitHub
parent 38ad0820c0
commit 5853891b02
675 changed files with 4894 additions and 4964 deletions
@@ -1,36 +0,0 @@
import { styled } from '@linaria/react';
import React from 'react';
import { Label } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledGroupHeading = styled(Label)`
align-items: center;
padding-bottom: ${themeCssVariables.spacing[1]};
padding-left: ${themeCssVariables.spacing[1]};
padding-right: ${themeCssVariables.spacing[1]};
padding-top: ${themeCssVariables.spacing[2]};
user-select: none;
`;
const StyledGroup = styled.div`
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[0.5]};
`;
type CommandGroupProps = {
heading: string;
children: React.ReactNode | React.ReactNode[];
};
export const CommandGroup = ({ heading, children }: CommandGroupProps) => {
if (!children || !React.Children.count(children)) {
return null;
}
return (
<>
<StyledGroupHeading>{heading}</StyledGroupHeading>
<StyledGroup>{children}</StyledGroup>
</>
);
};
@@ -1,105 +0,0 @@
import { type ActionConfig } from '@/action-menu/actions/types/ActionConfig';
import { CommandGroup } from '@/command-menu/components/CommandGroup';
import { CommandMenuList } from '@/command-menu/components/CommandMenuList';
import { ResetContextToSelectionCommandButton } from '@/command-menu/components/ResetContextToSelectionCommandButton';
import { RESET_CONTEXT_TO_SELECTION } from '@/command-menu/constants/ResetContextToSelection';
import { useMatchingCommandMenuActions } from '@/command-menu/hooks/useMatchingCommandMenuActions';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
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 { isDefined } from 'twenty-shared/utils';
export type ActionGroupConfig = {
heading: string;
items?: ActionConfig[];
};
export const CommandMenu = () => {
const { t } = useLingui();
const commandMenuSearch = useAtomStateValue(commandMenuSearchState);
const { objectMetadataItems } = useObjectMetadataItems();
const {
noResults,
matchingStandardActionRecordSelectionActions,
matchingStandardActionObjectActions,
matchingWorkflowRunRecordSelectionActions,
matchingFrontComponentRecordSelectionActions,
matchingStandardActionGlobalActions,
matchingWorkflowRunGlobalActions,
matchingFrontComponentGlobalActions,
matchingNavigateActions,
fallbackActions,
matchingCreateRelatedRecordActions,
} = useMatchingCommandMenuActions({
commandMenuSearch,
});
// eslint-disable-next-line twenty/matching-state-variable
const previousContextStoreCurrentObjectMetadataItemId =
useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
'command-menu-previous',
);
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
const currentObjectMetadataItem = objectMetadataItems.find(
(item) => item.id === contextStoreCurrentObjectMetadataItemId,
);
const commandGroups: ActionGroupConfig[] = [
{
heading: t`Record Selection`,
items: matchingStandardActionRecordSelectionActions
.concat(matchingWorkflowRunRecordSelectionActions)
.concat(matchingFrontComponentRecordSelectionActions),
},
{
heading: t`Create Related Record`,
items: matchingCreateRelatedRecordActions,
},
{
heading: currentObjectMetadataItem?.labelPlural ?? t`Object`,
items: matchingStandardActionObjectActions,
},
{
heading: t`Global`,
items: matchingStandardActionGlobalActions
.concat(matchingWorkflowRunGlobalActions)
.concat(matchingFrontComponentGlobalActions)
.concat(matchingNavigateActions),
},
{
heading: t`Search ''${commandMenuSearch}'' with...`,
items: fallbackActions,
},
];
const selectableItems = commandGroups.flatMap((group) => group.items ?? []);
const selectableItemIds = selectableItems.map((item) => item.key);
if (isDefined(previousContextStoreCurrentObjectMetadataItemId)) {
selectableItemIds.unshift(RESET_CONTEXT_TO_SELECTION);
}
return (
<CommandMenuList
commandGroups={commandGroups}
selectableItemIds={selectableItemIds}
noResults={noResults}
>
{isDefined(previousContextStoreCurrentObjectMetadataItemId) && (
<CommandGroup heading={t`Context`}>
<ResetContextToSelectionCommandButton />
</CommandGroup>
)}
</CommandMenuList>
);
};
@@ -1,28 +0,0 @@
import { Draggable } from '@hello-pangea/dnd';
import { type ReactNode } from 'react';
type CommandMenuAddToNavDraggablePlaceholderProps = {
index: number;
children: ReactNode;
};
export const CommandMenuAddToNavDraggablePlaceholder = ({
index,
children,
}: CommandMenuAddToNavDraggablePlaceholderProps) => (
<Draggable
draggableId={`add-to-nav-placeholder-${index}`}
index={index}
isDragDisabled={true}
>
{(provided) => (
<div
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.draggableProps}
>
{children}
</div>
)}
</Draggable>
);
@@ -1,25 +0,0 @@
import { Droppable, type DroppableProvided } from '@hello-pangea/dnd';
import { type ReactNode, useContext } from 'react';
import { ADD_TO_NAV_SOURCE_DROPPABLE_ID } from '@/navigation-menu-item/constants/AddToNavSourceDroppableId';
import { NavigationDragSourceContext } from '@/navigation-menu-item/contexts/NavigationDragSourceContext';
type CommandMenuAddToNavDroppableProps = {
children: (provided: DroppableProvided) => ReactNode;
};
export const CommandMenuAddToNavDroppable = ({
children,
}: CommandMenuAddToNavDroppableProps) => {
const { sourceDroppableId } = useContext(NavigationDragSourceContext);
const isDropDisabled = sourceDroppableId === ADD_TO_NAV_SOURCE_DROPPABLE_ID;
return (
<Droppable
droppableId={ADD_TO_NAV_SOURCE_DROPPABLE_ID}
isDropDisabled={isDropDisabled}
>
{(provided) => children(provided)}
</Droppable>
);
};
@@ -1,26 +0,0 @@
import { currentAIChatThreadTitleState } from '@/ai/states/currentAIChatThreadTitleState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledPageTitle = styled.div`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.semiBold};
`;
export const CommandMenuAskAIInfo = () => {
const currentAIChatThreadTitle = useAtomStateValue(
currentAIChatThreadTitleState,
);
return (
<StyledPageTitle>
<OverflowingTextWithTooltip
text={currentAIChatThreadTitle ?? t`Ask AI`}
/>
</StyledPageTitle>
);
};
@@ -1,85 +0,0 @@
import { COMMAND_MENU_NAVIGATION_HISTORY_DROPDOWN_ID } from '@/command-menu/constants/CommandMenuNavigationHistoryDropdownId';
import { useCommandMenuContextChips } from '@/command-menu/hooks/useCommandMenuContextChips';
import { useCommandMenuHistory } from '@/command-menu/hooks/useCommandMenuHistory';
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 { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown';
import { styled } from '@linaria/react';
import { IconChevronLeft } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledNavigationIcon = styled.div`
align-items: center;
cursor: pointer;
display: flex;
justify-content: center;
`;
const StyledIconChevronLeft = styled(IconChevronLeft)`
color: ${themeCssVariables.font.color.secondary};
`;
export const CommandMenuBackButton = () => {
const { goBackFromCommandMenu } = useCommandMenuHistory();
const { contextChips } = useCommandMenuContextChips();
const { openDropdown } = useOpenDropdown();
const { closeDropdown } = useCloseDropdown();
const handleBackButtonContextMenu = (
event: React.MouseEvent<HTMLDivElement>,
) => {
if (contextChips.length === 0) {
return;
}
event.preventDefault();
event.stopPropagation();
openDropdown({
dropdownComponentInstanceIdFromProps:
COMMAND_MENU_NAVIGATION_HISTORY_DROPDOWN_ID,
});
};
return (
<Dropdown
clickableComponent={
<StyledNavigationIcon onContextMenu={handleBackButtonContextMenu}>
<IconButton
Icon={StyledIconChevronLeft}
size="small"
variant="tertiary"
onClick={goBackFromCommandMenu}
/>
</StyledNavigationIcon>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
{contextChips.slice(0, -1).map((chip, index) => (
<MenuItem
key={index}
LeftComponent={chip.Icons}
onClick={() => {
closeDropdown(COMMAND_MENU_NAVIGATION_HISTORY_DROPDOWN_ID);
chip.onClick?.();
}}
text={chip.text}
/>
))}
</DropdownMenuItemsContainer>
</DropdownContent>
}
dropdownId={COMMAND_MENU_NAVIGATION_HISTORY_DROPDOWN_ID}
dropdownPlacement="bottom-start"
disableClickForClickableComponent={true}
/>
);
};
@@ -1,71 +0,0 @@
import { styled } from '@linaria/react';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledCommandMenuContainer = styled.div<{ isMobile: boolean }>`
max-height: ${({ isMobile }) => {
const mobileOffset = isMobile ? themeCssVariables.spacing[16] : '0px';
return `calc(100% - ${mobileOffset})`;
}};
display: flex;
flex-direction: column;
flex: 1;
`;
type CommandMenuContainerProps = {
children: React.ReactNode;
};
export const CommandMenuContainer = ({
children,
}: CommandMenuContainerProps) => {
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
COMMAND_MENU_COMPONENT_INSTANCE_ID,
);
const isMobile = useIsMobile();
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id === contextStoreCurrentObjectMetadataItemId,
);
const contextStoreCurrentViewId = useAtomComponentStateValue(
contextStoreCurrentViewIdComponentState,
COMMAND_MENU_COMPONENT_INSTANCE_ID,
);
const recordIndexId = getRecordIndexIdFromObjectNamePluralAndViewId(
objectMetadataItem?.namePlural ?? '',
contextStoreCurrentViewId ?? '',
);
return (
<RecordComponentInstanceContextsWrapper componentInstanceId={recordIndexId}>
<ContextStoreComponentInstanceContext.Provider
value={{ instanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID }}
>
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID }}
>
<StyledCommandMenuContainer isMobile={isMobile}>
{children}
</StyledCommandMenuContainer>
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</RecordComponentInstanceContextsWrapper>
);
};
@@ -1,96 +0,0 @@
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { Fragment } from 'react/jsx-runtime';
import { type CommandMenuPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledChip = styled.button<{
withText: boolean;
maxWidth?: string;
onClick?: () => void;
}>`
all: unset;
align-items: center;
justify-content: center;
background: ${themeCssVariables.background.transparent.light};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
box-sizing: border-box;
display: flex;
gap: ${themeCssVariables.spacing[1]};
height: ${themeCssVariables.spacing[6]};
/* If the chip has text, we add extra padding to have a more balanced design */
padding: 0
${({ withText }) =>
withText ? themeCssVariables.spacing[2] : themeCssVariables.spacing[1]};
font-family: inherit;
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.medium};
line-height: ${themeCssVariables.text.lineHeight.lg};
color: ${themeCssVariables.font.color.primary};
cursor: ${({ onClick }) => (isDefined(onClick) ? 'pointer' : 'default')};
&:hover {
background: ${({ onClick }) =>
isDefined(onClick)
? themeCssVariables.background.transparent.medium
: themeCssVariables.background.transparent.light};
}
max-width: ${({ maxWidth }) => maxWidth ?? ''};
`;
const StyledIconsContainer = styled.div`
align-items: center;
display: flex;
`;
const StyledEmptyText = styled.div`
color: ${themeCssVariables.font.color.tertiary};
`;
export type CommandMenuContextChipProps = {
Icons: React.ReactNode[];
text?: string;
onClick?: () => void;
testId?: string;
maxWidth?: string;
forceEmptyText?: boolean;
page?: {
page: CommandMenuPages;
pageId: string;
};
};
export const CommandMenuContextChip = ({
Icons,
text,
onClick,
testId,
maxWidth,
forceEmptyText = false,
}: CommandMenuContextChipProps) => {
return (
<StyledChip
withText={isNonEmptyString(text)}
onClick={onClick}
data-testid={testId}
maxWidth={maxWidth}
>
<StyledIconsContainer>
{Icons.map((Icon, index) => (
<Fragment key={index}>{Icon}</Fragment>
))}
</StyledIconsContainer>
{text?.trim?.() ? (
<OverflowingTextWithTooltip text={text} />
) : !forceEmptyText ? (
<StyledEmptyText>{t`Untitled`}</StyledEmptyText>
) : (
''
)}
</StyledChip>
);
};
@@ -1,13 +0,0 @@
import { styled } from '@linaria/react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledCommandMenuContextChipIconWrapper = styled.div`
align-items: center;
background: ${themeCssVariables.background.primary};
border-radius: ${themeCssVariables.border.radius.sm};
display: flex;
justify-content: center;
`;
export const CommandMenuContextChipIconWrapper =
StyledCommandMenuContextChipIconWrapper;
@@ -1,58 +0,0 @@
import { useGetStandardObjectIcon } from '@/object-metadata/hooks/useGetStandardObjectIcon';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useRecordChipData } from '@/object-record/hooks/useRecordChipData';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { styled } from '@linaria/react';
import { useContext } from 'react';
import { Avatar } from 'twenty-ui/display';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledIconWrapper = styled.div<{ withIconBackground?: boolean }>`
background: ${({ withIconBackground }) =>
withIconBackground ? themeCssVariables.background.primary : 'unset'};
border-radius: ${themeCssVariables.border.radius.sm};
border: 1px solid
${({ withIconBackground }) =>
withIconBackground
? themeCssVariables.border.color.medium
: 'transparent'};
&:not(:first-of-type) {
margin-left: -${themeCssVariables.spacing[1]};
}
display: flex;
align-items: center;
justify-content: center;
`;
export const CommandMenuContextRecordChipAvatars = ({
objectMetadataItem,
record,
}: {
objectMetadataItem: ObjectMetadataItem;
record: ObjectRecord;
}) => {
const { theme } = useContext(ThemeContext);
const { recordChipData } = useRecordChipData({
objectNameSingular: objectMetadataItem.nameSingular,
record,
});
const { Icon, IconColor } = useGetStandardObjectIcon(
objectMetadataItem.nameSingular,
);
return (
<StyledIconWrapper
withIconBackground={recordChipData.avatarType !== 'rounded'}
>
{Icon ? (
<Icon color={IconColor} size={theme.icon.size.sm} />
) : (
<Avatar
avatarUrl={recordChipData.avatarUrl}
placeholderColorSeed={recordChipData.recordId}
placeholder={recordChipData.name}
type={recordChipData.avatarType}
size="sm"
/>
)}
</StyledIconWrapper>
);
};
@@ -1,52 +0,0 @@
import { CommandMenuContextChip } from '@/command-menu/components/CommandMenuContextChip';
import { CommandMenuContextRecordChipAvatars } from '@/command-menu/components/CommandMenuContextRecordChipAvatars';
import { getSelectedRecordsContextText } from '@/command-menu/utils/getRecordContextText';
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const CommandMenuContextRecordsChip = ({
objectMetadataItemId,
instanceId,
}: {
objectMetadataItemId: string;
instanceId?: string;
}) => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataItemId,
});
const allowRequestsToTwentyIcons = useAtomStateValue(
allowRequestsToTwentyIconsState,
);
const { records, loading, totalCount } =
useFindManyRecordsSelectedInContextStore({
limit: 3,
instanceId,
});
if (loading || !totalCount || records.length === 0) {
return null;
}
const Avatars = records.map((record) => (
<CommandMenuContextRecordChipAvatars
objectMetadataItem={objectMetadataItem}
key={record.id}
record={record}
/>
));
return (
<CommandMenuContextChip
text={getSelectedRecordsContextText(
objectMetadataItem,
records,
totalCount,
allowRequestsToTwentyIcons,
)}
Icons={Avatars}
/>
);
};
@@ -1,13 +0,0 @@
import { CommandMenuPageLayoutInfoContent } from '@/command-menu/components/CommandMenuPageLayoutInfoContent';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { isDefined } from 'twenty-shared/utils';
export const CommandMenuDashboardPageLayoutInfo = () => {
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
if (!isDefined(pageLayoutId)) {
return null;
}
return <CommandMenuPageLayoutInfoContent pageLayoutId={pageLayoutId} />;
};
@@ -1,46 +0,0 @@
import { COMMAND_MENU_LIST_SELECTABLE_LIST_ID } from '@/command-menu/constants/CommandMenuListSelectableListId';
import { hasUserSelectedCommandState } from '@/command-menu/states/hasUserSelectedCommandState';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
export const CommandMenuDefaultSelectionEffect = ({
selectableItemIds,
}: {
selectableItemIds: string[];
}) => {
const { setSelectedItemId } = useSelectableList(
COMMAND_MENU_LIST_SELECTABLE_LIST_ID,
);
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
COMMAND_MENU_LIST_SELECTABLE_LIST_ID,
);
const hasUserSelectedCommand = useAtomStateValue(hasUserSelectedCommandState);
useEffect(() => {
if (
isDefined(selectedItemId) &&
selectableItemIds.includes(selectedItemId) &&
hasUserSelectedCommand
) {
return;
}
if (selectableItemIds.length > 0) {
setSelectedItemId(selectableItemIds[0]);
}
}, [
hasUserSelectedCommand,
selectableItemIds,
selectedItemId,
setSelectedItemId,
]);
return null;
};
@@ -1,110 +0,0 @@
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { useIcons } from 'twenty-ui/display';
import { CommandMenuPageInfoLayout } from '@/command-menu/components/CommandMenuPageInfoLayout';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuShouldFocusTitleInputComponentState } from '@/command-menu/states/commandMenuShouldFocusTitleInputComponentState';
import { NavigationMenuItemStyleIcon } from '@/navigation-menu-item/components/NavigationMenuItemStyleIcon';
import { FOLDER_ICON_DEFAULT } from '@/navigation-menu-item/constants/FolderIconDefault';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { useUpdateFolderInDraft } from '@/navigation-menu-item/hooks/useUpdateFolderInDraft';
import { useWorkspaceSectionItems } from '@/navigation-menu-item/hooks/useWorkspaceSectionItems';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { TitleInput } from '@/ui/input/components/TitleInput';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
const StyledClickableIconWrapper = styled.div`
cursor: pointer;
`;
export const CommandMenuFolderInfo = () => {
const { t } = useLingui();
const { getIcon } = useIcons();
const commandMenuPageInfo = useAtomStateValue(commandMenuPageInfoState);
const [
commandMenuShouldFocusTitleInput,
setCommandMenuShouldFocusTitleInput,
] = useAtomComponentState(
commandMenuShouldFocusTitleInputComponentState,
commandMenuPageInfo.instanceId,
);
const selectedNavigationMenuItemInEditMode = useAtomStateValue(
selectedNavigationMenuItemInEditModeState,
);
const items = useWorkspaceSectionItems();
const { updateFolderInDraft } = useUpdateFolderInDraft();
const defaultLabel = t`New folder`;
const placeholder = t`Folder name`;
const selectedItem = selectedNavigationMenuItemInEditMode
? items.find(
(item) =>
item.itemType === NavigationMenuItemType.FOLDER &&
item.id === selectedNavigationMenuItemInEditMode,
)
: undefined;
if (!selectedItem) return null;
const itemId = selectedItem.id;
const itemName = selectedItem.name ?? defaultLabel;
const handleChange = (text: string) => {
updateFolderInDraft(itemId, { name: text });
};
const handleSave = () => {
const trimmed = itemName.trim();
const finalName = trimmed.length > 0 ? trimmed : defaultLabel;
if (finalName !== itemName) {
updateFolderInDraft(itemId, { name: finalName });
}
};
const selectedIconKey = selectedItem.icon ?? FOLDER_ICON_DEFAULT;
const FolderIconComponent = getIcon(selectedIconKey);
return (
<CommandMenuPageInfoLayout
icon={
<IconPicker
dropdownId="command-menu-folder-icon-picker"
selectedIconKey={selectedIconKey}
onChange={({ iconKey }) =>
updateFolderInDraft(itemId, { icon: iconKey })
}
clickableComponent={
<StyledClickableIconWrapper>
<NavigationMenuItemStyleIcon
Icon={FolderIconComponent}
color={selectedItem.color}
/>
</StyledClickableIconWrapper>
}
/>
}
title={
<TitleInput
instanceId={`folder-name-${itemId}`}
sizeVariant="sm"
value={itemName}
onChange={handleChange}
placeholder={placeholder}
onEnter={handleSave}
onEscape={handleSave}
onClickOutside={handleSave}
onTab={handleSave}
onShiftTab={handleSave}
shouldFocus={commandMenuShouldFocusTitleInput}
onFocus={() => setCommandMenuShouldFocusTitleInput(false)}
/>
}
label={t`Folder`}
/>
);
};
@@ -1,6 +1,6 @@
import { CommandMenuOpenContainer } from '@/command-menu/components/CommandMenuOpenContainer';
import { CommandMenuRouter } from '@/command-menu/components/CommandMenuRouter';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { SidePanelRouter } from '@/side-panel/components/SidePanelRouter';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
@@ -13,16 +13,16 @@ const StyledCommandMenuMobileFullScreenContainer = styled.div`
`;
export const CommandMenuForMobile = () => {
const isCommandMenuOpened = useAtomStateValue(isCommandMenuOpenedState);
const isSidePanelOpened = useAtomStateValue(isSidePanelOpenedState);
return (
<AnimatePresence>
{isCommandMenuOpened && (
{isSidePanelOpened && (
<>
{createPortal(
<StyledCommandMenuMobileFullScreenContainer>
<CommandMenuOpenContainer>
<CommandMenuRouter />
<SidePanelRouter />
</CommandMenuOpenContainer>
</StyledCommandMenuMobileFullScreenContainer>,
document.body,
@@ -2,7 +2,7 @@ import {
CommandMenuItem,
type CommandMenuItemProps,
} from '@/command-menu/components/CommandMenuItem';
import { COMMAND_MENU_LIST_SELECTABLE_LIST_ID } from '@/command-menu/constants/CommandMenuListSelectableListId';
import { SIDE_PANEL_SELECTABLE_LIST_ID } from '@/side-panel/constants/SidePanelSelectableListId';
import {
Dropdown,
type DropdownProps,
@@ -37,7 +37,7 @@ export const CommandMenuItemDropdown = ({
);
const { setSelectedItemId } = useSelectableList(
COMMAND_MENU_LIST_SELECTABLE_LIST_ID,
SIDE_PANEL_SELECTABLE_LIST_ID,
);
return (
@@ -1,106 +0,0 @@
import { styled } from '@linaria/react';
import { Draggable } from '@hello-pangea/dnd';
import { useLingui } from '@lingui/react/macro';
import { type ReactNode, useState } from 'react';
import { type IconComponent } from 'twenty-ui/display';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { AddToNavigationDragHandle } from '@/navigation-menu-item/components/AddToNavigationDragHandle';
import { addToNavPayloadRegistryState } from '@/navigation-menu-item/states/addToNavPayloadRegistryState';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import type { AddToNavigationDragPayload } from '@/navigation-menu-item/types/add-to-navigation-drag-payload';
type CommandMenuItemWithAddToNavigationDragProps = {
icon?: IconComponent;
customIconContent?: ReactNode;
label: string;
description?: string;
id: string;
onClick: () => void;
payload: AddToNavigationDragPayload;
dragIndex?: number;
};
const StyledDraggableMenuItem = styled.div`
cursor: grab;
width: 100%;
&:active {
cursor: grabbing;
}
`;
export const CommandMenuItemWithAddToNavigationDrag = ({
icon,
customIconContent,
label,
description,
id,
onClick,
payload,
dragIndex,
}: CommandMenuItemWithAddToNavigationDragProps) => {
const { t } = useLingui();
const setAddToNavPayloadRegistry = useSetAtomState(
addToNavPayloadRegistryState,
);
const [isHovered, setIsHovered] = useState(false);
const contextualDescription = isHovered
? t`Drag to add to navbar`
: description;
const DragHandleIcon = () => (
<AddToNavigationDragHandle
icon={icon}
customIconContent={customIconContent}
payload={payload}
isHovered={isHovered}
/>
);
const registerPayload = () => {
if (dragIndex !== undefined) {
setAddToNavPayloadRegistry((prev) => new Map(prev).set(id, payload));
}
};
const menuItemContent = (
<StyledDraggableMenuItem
onMouseEnter={() => {
setIsHovered(true);
registerPayload();
}}
onMouseLeave={() => setIsHovered(false)}
onMouseDown={registerPayload}
>
<CommandMenuItem
Icon={DragHandleIcon}
label={label}
description={contextualDescription}
id={id}
onClick={onClick}
/>
</StyledDraggableMenuItem>
);
if (dragIndex !== undefined) {
return (
<Draggable draggableId={id} index={dragIndex} isDragDisabled={false}>
{(provided) => (
<div
ref={provided.innerRef}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.draggableProps}
// eslint-disable-next-line react/jsx-props-no-spreading
{...provided.dragHandleProps}
>
{menuItemContent}
</div>
)}
</Draggable>
);
}
return menuItemContent;
};
@@ -1,88 +0,0 @@
import { useLingui } from '@lingui/react/macro';
import { IconLink } from 'twenty-ui/display';
import { CommandMenuPageInfoLayout } from '@/command-menu/components/CommandMenuPageInfoLayout';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuShouldFocusTitleInputComponentState } from '@/command-menu/states/commandMenuShouldFocusTitleInputComponentState';
import { NavigationMenuItemStyleIcon } from '@/navigation-menu-item/components/NavigationMenuItemStyleIcon';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { useUpdateLinkInDraft } from '@/navigation-menu-item/hooks/useUpdateLinkInDraft';
import { useWorkspaceSectionItems } from '@/navigation-menu-item/hooks/useWorkspaceSectionItems';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { TitleInput } from '@/ui/input/components/TitleInput';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const CommandMenuLinkInfo = () => {
const { t } = useLingui();
const commandMenuPageInfo = useAtomStateValue(commandMenuPageInfoState);
const [
commandMenuShouldFocusTitleInput,
setCommandMenuShouldFocusTitleInput,
] = useAtomComponentState(
commandMenuShouldFocusTitleInputComponentState,
commandMenuPageInfo.instanceId,
);
const selectedNavigationMenuItemInEditMode = useAtomStateValue(
selectedNavigationMenuItemInEditModeState,
);
const items = useWorkspaceSectionItems();
const { updateLinkInDraft } = useUpdateLinkInDraft();
const defaultLabel = t`Link label`;
const placeholder = t`Link label`;
const selectedItem = selectedNavigationMenuItemInEditMode
? items.find(
(item) =>
item.itemType === NavigationMenuItemType.LINK &&
item.id === selectedNavigationMenuItemInEditMode,
)
: undefined;
if (!selectedItem) return null;
const itemId = selectedItem.id;
const itemName = selectedItem.name ?? defaultLabel;
const handleChange = (text: string) => {
updateLinkInDraft(itemId, { name: text });
};
const handleSave = () => {
const trimmed = itemName.trim();
const finalName = trimmed.length > 0 ? trimmed : defaultLabel;
if (finalName !== itemName) {
updateLinkInDraft(itemId, { name: finalName });
}
};
return (
<CommandMenuPageInfoLayout
icon={
<NavigationMenuItemStyleIcon
Icon={IconLink}
color={selectedItem.color}
/>
}
title={
<TitleInput
instanceId={`link-label-${itemId}`}
sizeVariant="sm"
value={itemName}
onChange={handleChange}
placeholder={placeholder}
onEnter={handleSave}
onEscape={handleSave}
onClickOutside={handleSave}
onTab={handleSave}
onShiftTab={handleSave}
shouldFocus={commandMenuShouldFocusTitleInput}
onFocus={() => setCommandMenuShouldFocusTitleInput(false)}
/>
}
label={t`Link`}
/>
);
};
@@ -1,103 +0,0 @@
import { ActionComponent } from '@/action-menu/actions/display/components/ActionComponent';
import { CommandGroup } from '@/command-menu/components/CommandGroup';
import { type ActionGroupConfig } from '@/command-menu/components/CommandMenu';
import { CommandMenuDefaultSelectionEffect } from '@/command-menu/components/CommandMenuDefaultSelectionEffect';
import { COMMAND_MENU_LIST_SELECTABLE_LIST_ID } from '@/command-menu/constants/CommandMenuListSelectableListId';
import { COMMAND_MENU_SEARCH_BAR_HEIGHT } from '@/command-menu/constants/CommandMenuSearchBarHeight';
import { COMMAND_MENU_SEARCH_BAR_PADDING } from '@/command-menu/constants/CommandMenuSearchBarPadding';
import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId';
import { hasUserSelectedCommandState } from '@/command-menu/states/hasUserSelectedCommandState';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { MOBILE_VIEWPORT, themeCssVariables } from 'twenty-ui/theme-constants';
export type CommandMenuListProps = {
commandGroups: ActionGroupConfig[];
selectableItemIds: string[];
children?: React.ReactNode;
loading?: boolean;
noResults?: boolean;
noResultsText?: string;
};
const StyledInnerList = styled.div`
max-height: calc(
100dvh - ${COMMAND_MENU_SEARCH_BAR_HEIGHT}px -
${COMMAND_MENU_SEARCH_BAR_PADDING * 2}px
);
padding-left: ${themeCssVariables.spacing[2]};
padding-right: ${themeCssVariables.spacing[2]};
padding-top: ${themeCssVariables.spacing[2]};
width: calc(100% - ${themeCssVariables.spacing[4]});
@media (min-width: ${MOBILE_VIEWPORT}px) {
max-height: calc(
100dvh - ${COMMAND_MENU_SEARCH_BAR_HEIGHT}px -
${COMMAND_MENU_SEARCH_BAR_PADDING * 2}px
);
}
`;
const StyledCommandMenuList = styled.div`
overflow-y: hidden;
`;
const StyledEmpty = styled.div`
align-items: center;
color: ${themeCssVariables.font.color.light};
display: flex;
font-size: ${themeCssVariables.font.size.md};
height: 64px;
justify-content: center;
white-space: pre-wrap;
`;
export const CommandMenuList = ({
commandGroups,
selectableItemIds,
children,
loading = false,
noResults = false,
noResultsText,
}: CommandMenuListProps) => {
const setHasUserSelectedCommand = useSetAtomState(
hasUserSelectedCommandState,
);
return (
<StyledCommandMenuList>
<CommandMenuDefaultSelectionEffect
selectableItemIds={selectableItemIds}
/>
<ScrollWrapper componentInstanceId={`scroll-wrapper-command-menu`}>
<StyledInnerList>
<SelectableList
selectableListInstanceId={COMMAND_MENU_LIST_SELECTABLE_LIST_ID}
focusId={SIDE_PANEL_FOCUS_ID}
selectableItemIdArray={selectableItemIds}
onSelect={() => {
setHasUserSelectedCommand(true);
}}
>
{children}
{commandGroups.map(({ heading, items }) =>
items?.length ? (
<CommandGroup heading={heading} key={heading}>
{items.map((item) => (
<ActionComponent action={item} key={item.key} />
))}
</CommandGroup>
) : null,
)}
{noResults && !loading && (
<StyledEmpty>{noResultsText ?? t`No results found`}</StyledEmpty>
)}
</SelectableList>
</StyledInnerList>
</ScrollWrapper>
</StyledCommandMenuList>
);
};
@@ -1,34 +0,0 @@
import { DEFAULT_RECORD_ACTIONS_CONFIG } from '@/action-menu/actions/record-actions/constants/DefaultRecordActionsConfig';
import { MultipleRecordsActionKeys } from '@/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys';
import { getActionLabel } from '@/action-menu/utils/getActionLabel';
import { CommandMenuPageInfoLayout } from '@/command-menu/components/CommandMenuPageInfoLayout';
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { ThemeContext } from 'twenty-ui/theme-constants';
type CommandMenuMultipleRecordsInfoProps = {
commandMenuPageInstanceId: string;
};
export const CommandMenuMultipleRecordsInfo = ({
commandMenuPageInstanceId,
}: CommandMenuMultipleRecordsInfoProps) => {
const { theme } = useContext(ThemeContext);
const { totalCount } = useFindManyRecordsSelectedInContextStore({
instanceId: commandMenuPageInstanceId,
limit: 1,
});
const { Icon, label } =
DEFAULT_RECORD_ACTIONS_CONFIG[MultipleRecordsActionKeys.UPDATE];
return (
<CommandMenuPageInfoLayout
icon={<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />}
iconColor={theme.font.color.tertiary}
title={getActionLabel(label)}
label={t`${totalCount} selected`}
/>
);
};
@@ -1,51 +0,0 @@
import { useLingui } from '@lingui/react/macro';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
import { CommandMenuPageInfoLayout } from '@/command-menu/components/CommandMenuPageInfoLayout';
import { NavigationMenuItemIcon } from '@/navigation-menu-item/components/NavigationMenuItemIcon';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { useSelectedNavigationMenuItemEditItem } from '@/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditItem';
import { useSelectedNavigationMenuItemEditItemLabel } from '@/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditItemLabel';
import { useSelectedNavigationMenuItemEditItemObjectMetadata } from '@/navigation-menu-item/hooks/useSelectedNavigationMenuItemEditItemObjectMetadata';
import { ViewKey } from '@/views/types/ViewKey';
export const CommandMenuObjectViewRecordInfo = () => {
const { t } = useLingui();
const { selectedItem } = useSelectedNavigationMenuItemEditItem();
const { selectedItemLabel } = useSelectedNavigationMenuItemEditItemLabel();
const { selectedItemObjectMetadata } =
useSelectedNavigationMenuItemEditItemObjectMetadata();
const processedItem =
selectedItem && selectedItem.itemType !== NavigationMenuItemType.FOLDER
? selectedItem
: undefined;
if (!processedItem || !selectedItemLabel) {
return null;
}
const isViewOrRecord = [
NavigationMenuItemType.VIEW,
NavigationMenuItemType.RECORD,
].includes(processedItem.itemType);
if (!isViewOrRecord) {
return null;
}
const label =
processedItem.itemType === NavigationMenuItemType.RECORD
? selectedItemObjectMetadata?.labelSingular
: processedItem.viewKey === ViewKey.Index
? t`Object`
: t`View`;
return (
<CommandMenuPageInfoLayout
icon={<NavigationMenuItemIcon navigationMenuItem={processedItem} />}
title={<OverflowingTextWithTooltip text={selectedItemLabel} />}
label={label}
/>
);
};
@@ -1,29 +1,29 @@
import { COMMAND_MENU_ANIMATION_VARIANTS } from '@/command-menu/constants/CommandMenuAnimationVariants';
import { COMMAND_MENU_CLICK_OUTSIDE_ID } from '@/command-menu/constants/CommandMenuClickOutsideId';
import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { isSidePanelAnimatingState } from '@/command-menu/states/isSidePanelAnimatingState';
import { type CommandMenuAnimationVariant } from '@/command-menu/types/CommandMenuAnimationVariant';
import { RECORD_CHIP_CLICK_OUTSIDE_ID } from '@/object-record/record-table/constants/RecordChipClickOutsideId';
import { SIDE_PANEL_ANIMATION_VARIANTS } from '@/side-panel/constants/SidePanelAnimationVariants';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { isSidePanelAnimatingState } from '@/side-panel/states/isSidePanelAnimatingState';
import { type SidePanelAnimationVariant } from '@/side-panel/types/SidePanelAnimationVariant';
import { MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/MentionMenuDropdownClickOutsideId';
import { SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/SlashMenuDropdownClickOutsideId';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page-header/constants/PageHeaderSidePanelButtonClickOutsideId';
import { NAVIGATION_DRAWER_CLICK_OUTSIDE_ID } from '@/ui/navigation/navigation-drawer/constants/NavigationDrawerClickOutsideId';
import { PAGE_HEADER_COMMAND_MENU_BUTTON_CLICK_OUTSIDE_ID } from '@/ui/layout/page-header/constants/PageHeaderCommandMenuButtonClickOutsideId';
import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector';
import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside';
import { useStore } from 'jotai';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { WORKFLOW_DIAGRAM_CREATE_STEP_NODE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramCreateStepNodeClickOutsideId';
import { WORKFLOW_DIAGRAM_STEP_NODE_BASE_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramStepNodeClickOutsideId';
import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/workflow-edges/constants/WorkflowDiagramEdgeOptionsClickOutsideId';
import { useStore } from 'jotai';
import { styled } from '@linaria/react';
import { motion } from 'framer-motion';
import { useCallback, useContext, useRef } from 'react';
import { LINK_CHIP_CLICK_OUTSIDE_ID } from 'twenty-ui/components';
import { useIsMobile } from 'twenty-ui/utilities';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
import { useIsMobile } from 'twenty-ui/utilities';
const StyledCommandMenuBase = styled.div`
background: ${themeCssVariables.background.primary};
border-left: 1px solid ${themeCssVariables.border.color.medium};
@@ -35,7 +35,7 @@ const StyledCommandMenuBase = styled.div`
position: fixed;
right: 0%;
top: 0%;
z-index: ${RootStackingContextZIndices.CommandMenu};
z-index: ${RootStackingContextZIndices.SidePanel};
display: flex;
flex-direction: column;
`;
@@ -47,10 +47,10 @@ export const CommandMenuOpenContainer = ({
const { theme } = useContext(ThemeContext);
const isMobile = useIsMobile();
const targetVariantForAnimation: CommandMenuAnimationVariant = isMobile
const targetVariantForAnimation: SidePanelAnimationVariant = isMobile
? 'fullScreen'
: 'normal';
const { closeCommandMenu } = useCommandMenu();
const { closeSidePanelMenu } = useSidePanelMenu();
const commandMenuRef = useRef<HTMLDivElement>(null);
const setIsSidePanelAnimating = useSetAtomState(isSidePanelAnimatingState);
@@ -64,10 +64,10 @@ export const CommandMenuOpenContainer = ({
if (currentFocusId === SIDE_PANEL_FOCUS_ID) {
event.stopImmediatePropagation();
event.preventDefault();
closeCommandMenu();
closeSidePanelMenu();
}
},
[closeCommandMenu, store],
[closeSidePanelMenu, store],
);
useListenClickOutside({
@@ -76,7 +76,7 @@ export const CommandMenuOpenContainer = ({
listenerId: 'COMMAND_MENU_LISTENER_ID',
excludedClickOutsideIds: [
NAVIGATION_DRAWER_CLICK_OUTSIDE_ID,
PAGE_HEADER_COMMAND_MENU_BUTTON_CLICK_OUTSIDE_ID,
PAGE_HEADER_SIDE_PANEL_BUTTON_CLICK_OUTSIDE_ID,
LINK_CHIP_CLICK_OUTSIDE_ID,
RECORD_CHIP_CLICK_OUTSIDE_ID,
SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID,
@@ -95,7 +95,7 @@ export const CommandMenuOpenContainer = ({
animate={targetVariantForAnimation}
initial="closed"
exit="closed"
variants={COMMAND_MENU_ANIMATION_VARIANTS}
variants={SIDE_PANEL_ANIMATION_VARIANTS}
transition={{
duration: theme.animation.duration.normal,
}}
@@ -1,149 +0,0 @@
import { styled } from '@linaria/react';
import { isDefined } from 'twenty-shared/utils';
import {
IconColumnInsertRight,
OverflowingTextWithTooltip,
} from 'twenty-ui/display';
import { CommandMenuAskAIInfo } from '@/command-menu/components/CommandMenuAskAIInfo';
import { CommandMenuFolderInfo } from '@/command-menu/components/CommandMenuFolderInfo';
import { CommandMenuLinkInfo } from '@/command-menu/components/CommandMenuLinkInfo';
import { CommandMenuMultipleRecordsInfo } from '@/command-menu/components/CommandMenuMultipleRecordsInfo';
import { CommandMenuObjectViewRecordInfo } from '@/command-menu/components/CommandMenuObjectViewRecordInfo';
import { CommandMenuPageInfoLayout } from '@/command-menu/components/CommandMenuPageInfoLayout';
import { CommandMenuPageLayoutInfo } from '@/command-menu/components/CommandMenuPageLayoutInfo';
import { CommandMenuRecordInfo } from '@/command-menu/components/CommandMenuRecordInfo';
import { CommandMenuWorkflowStepInfo } from '@/command-menu/components/CommandMenuWorkflowStepInfo';
import { NavigationMenuItemType } from '@/navigation-menu-item/constants/NavigationMenuItemType';
import { useWorkspaceSectionItems } from '@/navigation-menu-item/hooks/useWorkspaceSectionItems';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { CommandMenuPages } from 'twenty-shared/types';
import { useContext } from 'react';
import { type CommandMenuContextChipProps } from './CommandMenuContextChip';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledPageTitle = styled.div`
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.sm};
font-weight: ${themeCssVariables.font.weight.semiBold};
`;
type CommandMenuPageInfoProps = {
pageChip: CommandMenuContextChipProps | undefined;
};
export const CommandMenuPageInfo = ({ pageChip }: CommandMenuPageInfoProps) => {
const { theme } = useContext(ThemeContext);
const selectedNavigationMenuItemInEditMode = useAtomStateValue(
selectedNavigationMenuItemInEditModeState,
);
const items = useWorkspaceSectionItems();
if (!isDefined(pageChip)) {
return null;
}
const isNavigationMenuItemEditPage =
pageChip.page?.page === CommandMenuPages.NavigationMenuItemEdit;
const selectedNavItem = isNavigationMenuItemEditPage
? items.find((item) => item.id === selectedNavigationMenuItemInEditMode)
: undefined;
if (isNavigationMenuItemEditPage && isDefined(selectedNavItem)) {
const itemType = selectedNavItem.itemType;
if (itemType === NavigationMenuItemType.FOLDER) {
return <CommandMenuFolderInfo />;
}
if (itemType === NavigationMenuItemType.LINK) {
return <CommandMenuLinkInfo />;
}
if (
itemType === NavigationMenuItemType.VIEW ||
itemType === NavigationMenuItemType.RECORD
) {
return <CommandMenuObjectViewRecordInfo />;
}
}
const isRecordPage = pageChip.page?.page === CommandMenuPages.ViewRecord;
if (isRecordPage && isDefined(pageChip.page?.pageId)) {
return (
<CommandMenuRecordInfo commandMenuPageInstanceId={pageChip.page.pageId} />
);
}
const isWorkflowStepPage = pageChip.page?.page
? [
CommandMenuPages.WorkflowStepEdit,
CommandMenuPages.WorkflowStepView,
CommandMenuPages.WorkflowRunStepView,
].includes(pageChip.page?.page)
: false;
if (isWorkflowStepPage && isDefined(pageChip.page?.pageId)) {
return (
<CommandMenuWorkflowStepInfo
key={pageChip.page.pageId}
commandMenuPageInstanceId={pageChip.page.pageId}
/>
);
}
const isPageLayoutPage = pageChip.page?.page
? [
CommandMenuPages.PageLayoutWidgetTypeSelect,
CommandMenuPages.PageLayoutGraphTypeSelect,
CommandMenuPages.PageLayoutGraphFilter,
CommandMenuPages.PageLayoutIframeSettings,
CommandMenuPages.PageLayoutTabSettings,
CommandMenuPages.PageLayoutFieldsSettings,
CommandMenuPages.PageLayoutFieldsLayout,
].includes(pageChip.page?.page)
: false;
if (isPageLayoutPage) {
return <CommandMenuPageLayoutInfo />;
}
const isMultipleRecordsPage =
pageChip.page?.page === CommandMenuPages.UpdateRecords;
if (isMultipleRecordsPage && isDefined(pageChip.page?.pageId)) {
return (
<CommandMenuMultipleRecordsInfo
commandMenuPageInstanceId={pageChip.page.pageId}
/>
);
}
const isAskAIPage = pageChip.page?.page === CommandMenuPages.AskAI;
if (isAskAIPage) {
return <CommandMenuAskAIInfo />;
}
if (pageChip.page?.page === CommandMenuPages.NavigationMenuAddItem) {
return (
<CommandMenuPageInfoLayout
icon={
<IconColumnInsertRight
size={theme.icon.size.md}
color={theme.font.color.tertiary}
/>
}
title={<OverflowingTextWithTooltip text={pageChip.text ?? ''} />}
/>
);
}
return (
<StyledPageTitle>
<OverflowingTextWithTooltip text={pageChip.text ?? ''} />
</StyledPageTitle>
);
};
@@ -1,69 +0,0 @@
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const StyledPageInfoContainer = styled.div`
align-items: center;
display: flex;
gap: ${themeCssVariables.spacing[0.5]};
`;
export const StyledPageInfoIcon = styled.div<{ iconColor?: string }>`
align-items: center;
background: ${themeCssVariables.background.transparent.light};
border-radius: ${themeCssVariables.border.radius.sm};
color: ${({ iconColor }) => iconColor ?? ''};
display: flex;
flex-shrink: 0;
justify-content: center;
padding: ${themeCssVariables.spacing[1]};
`;
export const StyledPageInfoTextContainer = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: ${themeCssVariables.spacing[0.5]};
min-width: 0;
`;
export const StyledPageInfoTitleContainer = styled.div`
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.semiBold};
padding-inline: ${themeCssVariables.spacing[1]};
min-width: 0;
max-width: 150px;
`;
export const StyledPageInfoLabel = styled.div`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.sm};
white-space: nowrap;
flex-shrink: 0;
`;
type CommandMenuPageInfoLayoutProps = {
icon?: ReactNode;
iconColor?: string;
title: ReactNode;
label?: ReactNode;
};
export const CommandMenuPageInfoLayout = ({
icon,
iconColor,
title,
label,
}: CommandMenuPageInfoLayoutProps) => {
return (
<StyledPageInfoContainer>
{icon && (
<StyledPageInfoIcon iconColor={iconColor}>{icon}</StyledPageInfoIcon>
)}
<StyledPageInfoTextContainer>
<StyledPageInfoTitleContainer>{title}</StyledPageInfoTitleContainer>
{label && <StyledPageInfoLabel>{label}</StyledPageInfoLabel>}
</StyledPageInfoTextContainer>
</StyledPageInfoContainer>
);
};
@@ -1,30 +0,0 @@
import { CommandMenuDashboardPageLayoutInfo } from '@/command-menu/components/CommandMenuDashboardPageLayoutInfo';
import { CommandMenuRecordPageLayoutInfo } from '@/command-menu/components/CommandMenuRecordPageLayoutInfo';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { isDefined } from 'twenty-shared/utils';
export const CommandMenuPageLayoutInfo = () => {
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
);
if (!isDefined(contextStoreCurrentObjectMetadataItemId)) {
throw new Error('Object metadata ID is not defined');
}
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: contextStoreCurrentObjectMetadataItemId,
});
const isDashboardContext =
objectMetadataItem.nameSingular === CoreObjectNameSingular.Dashboard;
if (isDashboardContext) {
return <CommandMenuDashboardPageLayoutInfo />;
}
return <CommandMenuRecordPageLayoutInfo />;
};
@@ -1,147 +0,0 @@
import { usePageLayoutHeaderInfo } from '@/command-menu/components/hooks/usePageLayoutHeaderInfo';
import { useUpdateCommandMenuPageInfo } from '@/command-menu/hooks/useUpdateCommandMenuPageInfo';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { commandMenuShouldFocusTitleInputComponentState } from '@/command-menu/states/commandMenuShouldFocusTitleInputComponentState';
import { useUpdatePageLayoutTab } from '@/page-layout/hooks/useUpdatePageLayoutTab';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState';
import { TitleInput } from '@/ui/input/components/TitleInput';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { isNonEmptyString } from '@sniptt/guards';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useContext, useState } from 'react';
import { CommandMenuPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { CommandMenuPageInfoLayout } from './CommandMenuPageInfoLayout';
import { ThemeContext } from 'twenty-ui/theme-constants';
export const CommandMenuPageLayoutInfoContent = ({
pageLayoutId,
}: {
pageLayoutId: string;
}) => {
const { theme } = useContext(ThemeContext);
const { getIcon } = useIcons();
const commandMenuPage = useAtomStateValue(commandMenuPageState);
const commandMenuPageInfo = useAtomStateValue(commandMenuPageInfoState);
const [
commandMenuShouldFocusTitleInput,
setCommandMenuShouldFocusTitleInput,
] = useAtomComponentState(
commandMenuShouldFocusTitleInputComponentState,
commandMenuPageInfo.instanceId,
);
const handleTitleInputOpen = () => {
setCommandMenuShouldFocusTitleInput(false);
};
const pageLayoutDraft = useAtomComponentStateValue(
pageLayoutDraftComponentState,
pageLayoutId,
);
const pageLayoutEditingWidgetId = useAtomComponentStateValue(
pageLayoutEditingWidgetIdComponentState,
pageLayoutId,
);
const [pageLayoutTabSettingsOpenTabId] = useAtomComponentState(
pageLayoutTabSettingsOpenTabIdComponentState,
pageLayoutId,
);
const { updateCommandMenuPageInfo } = useUpdateCommandMenuPageInfo();
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
const { updatePageLayoutTab } = useUpdatePageLayoutTab(pageLayoutId);
const [editedTitle, setEditedTitle] = useState<string | null>(null);
const headerInfo = usePageLayoutHeaderInfo({
commandMenuPage,
draftPageLayout: pageLayoutDraft,
pageLayoutEditingWidgetId,
openTabId: pageLayoutTabSettingsOpenTabId,
editedTitle,
});
if (!headerInfo) {
return null;
}
const {
headerIcon,
headerIconColor,
headerType,
title,
isReadonly,
tab,
widgetInEditMode,
} = headerInfo;
const Icon = headerIcon ?? getIcon('IconDefault');
const handleTitleChange = (newTitle: string) => {
setEditedTitle(newTitle);
};
const saveTitle = async () => {
const finalTitle = editedTitle ?? title;
if (!isNonEmptyString(finalTitle)) {
return;
}
updateCommandMenuPageInfo({
pageTitle: finalTitle,
pageIcon: Icon,
});
if (
commandMenuPage === CommandMenuPages.PageLayoutTabSettings &&
isDefined(tab)
) {
updatePageLayoutTab(tab.id, { title: finalTitle });
} else if (isDefined(widgetInEditMode)) {
updatePageLayoutWidget(widgetInEditMode.id, {
title: finalTitle,
});
}
setEditedTitle(null);
};
return (
<CommandMenuPageInfoLayout
icon={
isDefined(headerIcon) ? (
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
) : undefined
}
iconColor={headerIconColor}
title={
<TitleInput
instanceId={`page-layout-title-${commandMenuPage}-${pageLayoutId}`}
disabled={isReadonly}
sizeVariant="sm"
value={title}
onChange={handleTitleChange}
placeholder={headerType}
onEnter={saveTitle}
onEscape={() => setEditedTitle(null)}
onClickOutside={saveTitle}
onTab={saveTitle}
onShiftTab={saveTitle}
shouldFocus={commandMenuShouldFocusTitleInput}
onFocus={handleTitleInputOpen}
/>
}
label={headerType}
/>
);
};
@@ -1,138 +0,0 @@
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
import { viewableRecordIdComponentState } from '@/command-menu/pages/record-page/states/viewableRecordIdComponentState';
import { viewableRecordNameSingularComponentState } from '@/command-menu/pages/record-page/states/viewableRecordNameSingularComponentState';
import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsRecordFieldReadOnly';
import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext';
import { useRecordShowContainerActions } from '@/object-record/record-show/hooks/useRecordShowContainerActions';
import { useRecordShowPage } from '@/object-record/record-show/hooks/useRecordShowPage';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { recordStoreIdentifierFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreIdentifierFamilySelector';
import { RecordTitleCell } from '@/object-record/record-title-cell/components/RecordTitleCell';
import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { Trans } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { Avatar } from 'twenty-ui/display';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { CommandMenuPageInfoLayout } from './CommandMenuPageInfoLayout';
export const CommandMenuRecordInfo = ({
commandMenuPageInstanceId,
}: {
commandMenuPageInstanceId: string;
}) => {
const viewableRecordNameSingular = useAtomComponentStateValue(
viewableRecordNameSingularComponentState,
commandMenuPageInstanceId,
);
const allowRequestsToTwentyIcons = useAtomStateValue(
allowRequestsToTwentyIconsState,
);
const viewableRecordId = useAtomComponentStateValue(
viewableRecordIdComponentState,
commandMenuPageInstanceId,
);
const { objectNameSingular, objectRecordId } = useRecordShowPage(
viewableRecordNameSingular!,
viewableRecordId!,
);
const recordCreatedAt = useAtomFamilySelectorValue(
recordStoreFamilySelector,
{
recordId: objectRecordId,
fieldName: 'createdAt',
},
) as string | null;
const recordIdentifier = useAtomFamilySelectorValue(
recordStoreIdentifierFamilySelector,
{
recordId: objectRecordId,
allowRequestsToTwentyIcons,
},
);
const { localeCatalog } = useAtomStateValue(dateLocaleState);
const beautifiedCreatedAt = isNonEmptyString(recordCreatedAt)
? beautifyPastDateRelativeToNow(recordCreatedAt, localeCatalog)
: '';
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { labelIdentifierFieldMetadataItem } =
useLabelIdentifierFieldMetadataItem({
objectNameSingular,
});
const isTitleReadOnly = useIsRecordFieldReadOnly({
recordId: objectRecordId,
fieldMetadataId: labelIdentifierFieldMetadataItem?.id ?? '',
objectMetadataId: objectMetadataItem.id,
});
const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({
objectNameSingular,
});
const fieldDefinition = {
type: labelIdentifierFieldMetadataItem?.type ?? FieldMetadataType.TEXT,
iconName: '',
fieldMetadataId: labelIdentifierFieldMetadataItem?.id ?? '',
label: labelIdentifierFieldMetadataItem?.label ?? '',
metadata: {
fieldName: labelIdentifierFieldMetadataItem?.name ?? '',
objectMetadataNameSingular: objectNameSingular,
},
defaultValue: labelIdentifierFieldMetadataItem?.defaultValue,
};
return (
<CommandMenuPageInfoLayout
icon={
recordIdentifier ? (
<Avatar
avatarUrl={recordIdentifier.avatarUrl}
placeholder={recordIdentifier.name}
placeholderColorSeed={objectRecordId}
size="md"
type={recordIdentifier.avatarType}
/>
) : undefined
}
title={
<FieldContext.Provider
value={{
recordId: objectRecordId,
isLabelIdentifier: false,
fieldDefinition,
useUpdateRecord: useUpdateOneObjectRecordMutation,
isCentered: false,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isTitleReadOnly,
}}
>
<RecordTitleCell
sizeVariant="sm"
containerType={RecordTitleCellContainerType.PageHeader}
/>
</FieldContext.Provider>
}
label={
beautifiedCreatedAt ? (
<Trans>Created {beautifiedCreatedAt}</Trans>
) : undefined
}
/>
);
};
@@ -1,9 +0,0 @@
import { CommandMenuPageLayoutInfoContent } from '@/command-menu/components/CommandMenuPageLayoutInfoContent';
import { usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord';
export const CommandMenuRecordPageLayoutInfo = () => {
const { pageLayoutId } =
usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord();
return <CommandMenuPageLayoutInfoContent pageLayoutId={pageLayoutId} />;
};
@@ -1,58 +0,0 @@
import { ActionMenuContextProvider } from '@/action-menu/contexts/ActionMenuContextProvider';
import { CommandMenuContainer } from '@/command-menu/components/CommandMenuContainer';
import { CommandMenuTopBar } from '@/command-menu/components/CommandMenuTopBar';
import { COMMAND_MENU_PAGES_CONFIG } from '@/command-menu/constants/CommandMenuPagesConfig';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { styled } from '@linaria/react';
import { motion } from 'framer-motion';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { ThemeContext } from 'twenty-ui/theme-constants';
const StyledCommandMenuContent = styled.div`
flex: 1;
overflow-y: auto;
`;
export const CommandMenuRouter = () => {
const { theme } = useContext(ThemeContext);
const commandMenuPage = useAtomStateValue(commandMenuPageState);
const commandMenuPageInfo = useAtomStateValue(commandMenuPageInfoState);
const commandMenuPageComponent = isDefined(commandMenuPage) ? (
COMMAND_MENU_PAGES_CONFIG.get(commandMenuPage)
) : (
<></>
);
return (
<CommandMenuContainer>
<CommandMenuPageComponentInstanceContext.Provider
value={{ instanceId: commandMenuPageInfo.instanceId }}
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: theme.animation.duration.instant,
delay: 0.1,
}}
>
<CommandMenuTopBar />
</motion.div>
<StyledCommandMenuContent>
<ActionMenuContextProvider
isInRightDrawer={true}
displayType="listItem"
actionMenuType="command-menu"
>
{commandMenuPageComponent}
</ActionMenuContextProvider>
</StyledCommandMenuContent>
</CommandMenuPageComponentInstanceContext.Provider>
</CommandMenuContainer>
);
};
@@ -1,153 +0,0 @@
import { CommandMenuRouter } from '@/command-menu/components/CommandMenuRouter';
import { CommandMenuWidthEffect } from '@/command-menu/components/CommandMenuWidthEffect';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useCommandMenuCloseAnimationCompleteCleanup } from '@/command-menu/hooks/useCommandMenuCloseAnimationCompleteCleanup';
import {
COMMAND_MENU_WIDTH_VAR,
commandMenuWidthState,
} from '@/command-menu/states/commandMenuWidthState';
import { isCommandMenuClosingState } from '@/command-menu/states/isCommandMenuClosingState';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState';
import { ModalContainerContext } from '@/ui/layout/modal/contexts/ModalContainerContext';
import { ResizablePanelGap } from '@/ui/layout/resizable-panel/components/ResizablePanelGap';
import { COMMAND_MENU_CONSTRAINTS } from '@/ui/layout/resizable-panel/constants/CommandMenuConstraints';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { styled } from '@linaria/react';
import { useCallback, useState } from 'react';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledSidePanelWrapper = styled.div<{
isOpen: boolean;
isResizing: boolean;
}>`
flex-shrink: 0;
min-width: 0;
overflow: hidden;
width: ${({ isOpen }) => (isOpen ? `var(${COMMAND_MENU_WIDTH_VAR})` : '0px')};
transition: ${({ isResizing }) =>
isResizing
? 'none'
: `width ${themeCssVariables.animation.duration.normal}s`};
`;
const StyledSidePanel = styled.aside`
background: ${themeCssVariables.background.primary};
border: 1px solid ${themeCssVariables.border.color.medium};
border-radius: ${themeCssVariables.border.radius.md};
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
position: relative;
width: 100%;
box-sizing: border-box;
`;
const StyledModalContainer = styled.div`
height: 100%;
left: 0;
pointer-events: none;
position: absolute;
top: 0;
width: 100%;
z-index: 1;
`;
const GAP_WIDTH = 8;
export const CommandMenuSidePanelForDesktop = () => {
const isCommandMenuOpened = useAtomStateValue(isCommandMenuOpenedState);
const isCommandMenuClosing = useAtomStateValue(isCommandMenuClosingState);
const [commandMenuWidth, setCommandMenuWidth] = useAtomState(
commandMenuWidthState,
);
const { closeCommandMenu } = useCommandMenu();
const { commandMenuCloseAnimationCompleteCleanup } =
useCommandMenuCloseAnimationCompleteCleanup();
const [modalContainer, setModalContainer] = useState<HTMLDivElement | null>(
null,
);
const [isResizing, setIsResizing] = useState(false);
const [shouldRenderContent, setShouldRenderContent] =
useState(isCommandMenuOpened);
const setTableWidthResizeIsActive = useSetAtomState(
tableWidthResizeIsActiveState,
);
const shouldShowContent = isCommandMenuOpened || shouldRenderContent;
const handleTransitionEnd = () => {
if (isCommandMenuOpened) {
// Open animation completed - ensure content persists for close animation
setShouldRenderContent(true);
} else {
// Close animation completed
setShouldRenderContent(false);
if (isCommandMenuClosing) {
commandMenuCloseAnimationCompleteCleanup();
}
}
};
const handleModalContainerRef = useCallback(
(element: HTMLDivElement | null) => {
setModalContainer(element);
},
[],
);
const handleWidthChange = useCallback(
(width: number) => {
setCommandMenuWidth(width);
setIsResizing(false);
setTableWidthResizeIsActive(true);
},
[setCommandMenuWidth, setTableWidthResizeIsActive],
);
const handleResizeStart = useCallback(() => {
setIsResizing(true);
setTableWidthResizeIsActive(false);
}, [setTableWidthResizeIsActive]);
const handleCollapse = useCallback(() => {
closeCommandMenu();
setIsResizing(false);
setTableWidthResizeIsActive(true);
}, [closeCommandMenu, setTableWidthResizeIsActive]);
return (
<>
<CommandMenuWidthEffect />
<ResizablePanelGap
side="left"
constraints={COMMAND_MENU_CONSTRAINTS}
currentWidth={commandMenuWidth}
onWidthChange={handleWidthChange}
onCollapse={handleCollapse}
gapWidth={isCommandMenuOpened ? GAP_WIDTH : 0}
cssVariableName={COMMAND_MENU_WIDTH_VAR}
onResizeStart={handleResizeStart}
/>
<StyledSidePanelWrapper
isOpen={isCommandMenuOpened}
isResizing={isResizing}
onTransitionEnd={handleTransitionEnd}
data-command-menu-panel=""
>
<StyledSidePanel>
<StyledModalContainer ref={handleModalContainerRef} />
<ModalContainerContext.Provider value={{ container: modalContainer }}>
{shouldShowContent && <CommandMenuRouter />}
</ModalContainerContext.Provider>
</StyledSidePanel>
</StyledSidePanelWrapper>
</>
);
};
@@ -1,90 +0,0 @@
import { styled } from '@linaria/react';
import { type ReactNode } from 'react';
import { SidePanelSubPageNavigationHeader } from '@/command-menu/pages/common/components/SidePanelSubPageNavigationHeader';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledSubViewContainer = styled.div`
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
`;
const StyledSearchContainer = styled.div`
align-items: center;
border-bottom: 1px solid ${themeCssVariables.border.color.light};
display: flex;
height: ${themeCssVariables.spacing[10]};
min-width: 0;
padding-inline: ${themeCssVariables.spacing[3]};
`;
const StyledSearchInput = styled.input`
background: transparent;
border: none;
border-radius: ${themeCssVariables.border.radius.sm};
box-sizing: border-box;
padding: 0;
width: 100%;
outline: none;
&::placeholder {
color: ${themeCssVariables.font.color.tertiary};
}
`;
const StyledScrollableListWrapper = styled.div`
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
& > * {
flex: 1;
min-height: 0;
}
`;
type CommandMenuSubViewWithSearchProps = {
backBarTitle: string;
onBack: () => void;
searchPlaceholder: string;
searchValue: string;
onSearchChange: (value: string) => void;
searchInputProps?: React.InputHTMLAttributes<HTMLInputElement>;
children?: ReactNode;
};
export const CommandMenuSubViewWithSearch = ({
backBarTitle,
onBack,
searchPlaceholder,
searchValue,
onSearchChange,
searchInputProps,
children,
}: CommandMenuSubViewWithSearchProps) => (
<StyledSubViewContainer>
<SidePanelSubPageNavigationHeader
title={backBarTitle}
onBackClick={onBack}
/>
<StyledSearchContainer>
<StyledSearchInput
placeholder={searchPlaceholder}
value={searchValue}
onChange={(event) => onSearchChange(event.target.value)}
autoFocus
// eslint-disable-next-line react/jsx-props-no-spreading
{...searchInputProps}
/>
</StyledSearchContainer>
{children != null && (
<StyledScrollableListWrapper>{children}</StyledScrollableListWrapper>
)}
</StyledSubViewContainer>
);
@@ -1,192 +0,0 @@
import { CommandMenuBackButton } from '@/command-menu/components/CommandMenuBackButton';
import { CommandMenuPageInfo } from '@/command-menu/components/CommandMenuPageInfo';
import { CommandMenuTopBarInputFocusEffect } from '@/command-menu/components/CommandMenuTopBarInputFocusEffect';
import { CommandMenuTopBarRightCornerIcon } from '@/command-menu/components/CommandMenuTopBarRightCornerIcon';
import { COMMAND_MENU_SEARCH_BAR_HEIGHT } from '@/command-menu/constants/CommandMenuSearchBarHeight';
import { COMMAND_MENU_SEARCH_BAR_HEIGHT_MOBILE } from '@/command-menu/constants/CommandMenuSearchBarHeightMobile';
import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useCommandMenuContextChips } from '@/command-menu/hooks/useCommandMenuContextChips';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { useAtomState } from '@/ui/utilities/state/jotai/hooks/useAtomState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useContext, useRef } from 'react';
import { CommandMenuPages } from 'twenty-shared/types';
import { IconX } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { useIsMobile } from 'twenty-ui/utilities';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledInputContainer = styled.div<{ isMobile: boolean }>`
align-items: center;
background-color: ${themeCssVariables.background.secondary};
border: none;
border-bottom: 1px solid ${themeCssVariables.border.color.medium};
border-radius: 0;
box-sizing: border-box;
display: flex;
justify-content: space-between;
font-size: ${themeCssVariables.font.size.lg};
height: ${({ isMobile }) =>
isMobile
? COMMAND_MENU_SEARCH_BAR_HEIGHT_MOBILE
: COMMAND_MENU_SEARCH_BAR_HEIGHT}px;
margin: 0;
outline: none;
position: relative;
overflow: hidden;
padding: 0 ${themeCssVariables.spacing[2]};
gap: ${themeCssVariables.spacing[4]};
flex-shrink: 0;
justify-content: space-between;
`;
const StyledInput = styled.input`
border: none;
border-radius: 0;
background-color: transparent;
color: ${themeCssVariables.font.color.primary};
font-size: ${themeCssVariables.font.size.md};
margin: 0;
outline: none;
height: 24px;
padding: 0;
flex: 1;
&::placeholder {
color: ${themeCssVariables.font.color.light};
font-weight: ${themeCssVariables.font.weight.medium};
}
`;
const StyledContentContainer = styled.div`
align-items: center;
display: flex;
flex: 1;
gap: ${themeCssVariables.spacing[1]};
min-width: 0;
overflow: hidden;
`;
export const CommandMenuTopBar = () => {
const { theme } = useContext(ThemeContext);
const [commandMenuSearch, setCommandMenuSearch] = useAtomState(
commandMenuSearchState,
);
const inputRef = useRef<HTMLInputElement>(null);
const { t } = useLingui();
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setCommandMenuSearch(event.target.value);
};
const isMobile = useIsMobile();
const { closeCommandMenu } = useCommandMenu();
const commandMenuPage = useAtomStateValue(commandMenuPageState);
const commandMenuNavigationStack = useAtomStateValue(
commandMenuNavigationStackState,
);
const { contextChips } = useCommandMenuContextChips();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const handleInputFocus = () => {
pushFocusItemToFocusStack({
focusId: SIDE_PANEL_FOCUS_ID,
component: {
type: FocusComponentType.TEXT_INPUT,
instanceId: SIDE_PANEL_FOCUS_ID,
},
globalHotkeysConfig: {
enableGlobalHotkeysConflictingWithKeyboard: false,
},
});
};
const handleInputBlur = () => {
removeFocusItemFromFocusStackById({
focusId: SIDE_PANEL_FOCUS_ID,
});
};
const canGoBack = commandMenuNavigationStack.length > 1;
const shouldShowCloseButton =
!isMobile && commandMenuNavigationStack.length === 1;
const shouldShowBackButton = canGoBack;
const lastChip = contextChips.at(-1);
return (
<StyledInputContainer isMobile={isMobile}>
<StyledContentContainer>
<AnimatePresence>
{shouldShowBackButton && (
<motion.div
exit={{ opacity: 0, width: 0 }}
transition={{
duration: theme.animation.duration.instant,
}}
>
<CommandMenuBackButton />
</motion.div>
)}
{shouldShowCloseButton && (
<motion.div
exit={{ opacity: 0, width: 0 }}
transition={{
duration: theme.animation.duration.instant,
}}
>
<IconButton
Icon={IconX}
size="small"
variant="tertiary"
onClick={closeCommandMenu}
/>
</motion.div>
)}
</AnimatePresence>
{lastChip &&
commandMenuPage !== CommandMenuPages.Root &&
commandMenuPage !== CommandMenuPages.SearchRecords && (
<CommandMenuPageInfo pageChip={lastChip} />
)}
{(commandMenuPage === CommandMenuPages.Root ||
commandMenuPage === CommandMenuPages.SearchRecords) && (
<>
<StyledInput
data-testid={SIDE_PANEL_FOCUS_ID}
ref={inputRef}
value={commandMenuSearch}
placeholder={t`Type anything...`}
onChange={handleSearchChange}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
/>
<CommandMenuTopBarInputFocusEffect inputRef={inputRef} />
</>
)}
</StyledContentContainer>
<CommandMenuTopBarRightCornerIcon />
</StyledInputContainer>
);
};
@@ -1,25 +0,0 @@
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useEffect } from 'react';
import { CommandMenuPages } from 'twenty-shared/types';
type CommandMenuTopBarInputFocusEffectProps = {
inputRef: React.RefObject<HTMLInputElement>;
};
export const CommandMenuTopBarInputFocusEffect = ({
inputRef,
}: CommandMenuTopBarInputFocusEffectProps) => {
const commandMenuPage = useAtomStateValue(commandMenuPageState);
useEffect(() => {
if (
commandMenuPage === CommandMenuPages.Root ||
commandMenuPage === CommandMenuPages.SearchRecords
) {
inputRef.current?.focus();
}
}, [commandMenuPage, inputRef]);
return null;
};
@@ -1,56 +0,0 @@
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
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 { CommandMenuPages } from 'twenty-shared/types';
import { IconEdit, IconSparkles } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { useIsMobile } from 'twenty-ui/utilities';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledIconButton = styled(IconButton)`
color: ${themeCssVariables.font.color.secondary};
`;
export const CommandMenuTopBarRightCornerIcon = () => {
const isMobile = useIsMobile();
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const commandMenuPage = useAtomStateValue(commandMenuPageState);
const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
const { createChatThread } = useCreateNewAIChatThread();
if (isMobile || !isAiEnabled) {
return null;
}
const isOnAskAIPage = [
CommandMenuPages.AskAI,
CommandMenuPages.ViewPreviousAIChats,
].includes(commandMenuPage);
if (!isOnAskAIPage) {
return (
<StyledIconButton
onClick={() => openAskAIPage({ resetNavigationStack: false })}
Icon={IconSparkles}
variant="tertiary"
size="small"
/>
);
}
return (
<StyledIconButton
Icon={IconEdit}
size="small"
variant="tertiary"
onClick={() => createChatThread()}
ariaLabel={t`New conversation`}
/>
);
};
@@ -1,20 +0,0 @@
import { useEffect } from 'react';
import {
COMMAND_MENU_WIDTH_VAR,
commandMenuWidthState,
} from '@/command-menu/states/commandMenuWidthState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
export const CommandMenuWidthEffect = () => {
const commandMenuWidth = useAtomStateValue(commandMenuWidthState);
useEffect(() => {
document.documentElement.style.setProperty(
COMMAND_MENU_WIDTH_VAR,
`${commandMenuWidth}px`,
);
}, [commandMenuWidth]);
return null;
};
@@ -1,195 +0,0 @@
import { useUpdateCommandMenuPageInfo } from '@/command-menu/hooks/useUpdateCommandMenuPageInfo';
import { useCommandMenuWorkflowIdOrThrow } from '@/command-menu/pages/workflow/hooks/useCommandMenuWorkflowIdOrThrow';
import { commandMenuWorkflowStepIdComponentState } from '@/command-menu/pages/workflow/states/commandMenuWorkflowStepIdComponentState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { TitleInput } from '@/ui/input/components/TitleInput';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { getAgentIdFromStep } from '@/workflow/utils/getAgentIdFromStep';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { getWorkflowVisualizerComponentInstanceId } from '@/workflow/utils/getWorkflowVisualizerComponentInstanceId';
import { useUpdateAgentLabel } from '@/workflow/workflow-steps/hooks/useUpdateAgentLabel';
import { useUpdateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { getActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { getTriggerIconColor } from '@/workflow/workflow-trigger/utils/getTriggerIconColor';
import { t } from '@lingui/core/macro';
import { useContext, useState } from 'react';
import { CommandMenuPages, CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { useIcons } from 'twenty-ui/display';
import { CommandMenuPageInfoLayout } from './CommandMenuPageInfoLayout';
import { ThemeContext } from 'twenty-ui/theme-constants';
export const CommandMenuWorkflowStepInfo = ({
commandMenuPageInstanceId,
}: {
commandMenuPageInstanceId: string;
}) => {
const { theme } = useContext(ThemeContext);
const { getIcon } = useIcons();
const commandMenuPage = useAtomStateValue(commandMenuPageState);
const workflowId = useCommandMenuWorkflowIdOrThrow();
const commandMenuWorkflowStepId = useAtomComponentStateValue(
commandMenuWorkflowStepIdComponentState,
commandMenuPageInstanceId,
);
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
const isReadonly =
commandMenuPage === CommandMenuPages.WorkflowStepView ||
commandMenuPage === CommandMenuPages.WorkflowRunStepView;
const { updateCommandMenuPageInfo } = useUpdateCommandMenuPageInfo();
const instanceId = getWorkflowVisualizerComponentInstanceId({
recordId: workflowId,
});
const { getUpdatableWorkflowVersion } =
useGetUpdatableWorkflowVersionOrThrow(instanceId);
const { updateWorkflowVersionStep } = useUpdateWorkflowVersionStep();
const { updateOneRecord: updateOneWorkflowVersion } = useUpdateOneRecord();
const {
trigger,
steps,
id: workflowVersionId,
} = workflowWithCurrentVersion?.currentVersion ?? {
trigger: null,
steps: null,
id: undefined,
};
const isTriggerStep = commandMenuWorkflowStepId === TRIGGER_STEP_ID;
const stepDefinition =
isDefined(commandMenuWorkflowStepId) && isDefined(trigger)
? isTriggerStep || isDefined(steps)
? getStepDefinitionOrThrow({
stepId: commandMenuWorkflowStepId,
trigger,
steps,
})
: undefined
: undefined;
const isTrigger = stepDefinition?.type === 'trigger';
const agentId = getAgentIdFromStep(stepDefinition);
const { updateAgentLabel } = useUpdateAgentLabel(agentId);
const stepName =
isDefined(stepDefinition) && isDefined(stepDefinition.definition)
? isTrigger
? (stepDefinition.definition.name ??
(stepDefinition.definition.type === 'MANUAL'
? t`Launch manually`
: t`Trigger`))
: (stepDefinition.definition.name ?? t`Action`)
: '';
const [editedTitle, setEditedTitle] = useState<string | null>(null);
const title =
editedTitle ?? (isDefined(stepName) && stepName !== '' ? stepName : '');
const handleTitleChange = (newTitle: string) => {
setEditedTitle(newTitle);
};
if (
!isDefined(workflowId) ||
!isDefined(commandMenuWorkflowStepId) ||
!isDefined(workflowWithCurrentVersion?.currentVersion) ||
!isDefined(stepDefinition) ||
!isDefined(stepDefinition.definition)
) {
return null;
}
const headerIcon = isTrigger
? getTriggerIcon(stepDefinition.definition)
: getActionIcon(stepDefinition.definition.type);
const headerIconColor = isTrigger
? getTriggerIconColor(stepDefinition.definition.type)
: getActionIconColorOrThrow(stepDefinition.definition.type);
const headerType = isTrigger ? t`Trigger` : t`Action`;
const Icon = getIcon(headerIcon ?? 'IconDefault');
const saveTitle = async () => {
if (!isDefined(workflowVersionId) || !isDefined(workflowId)) {
return;
}
updateCommandMenuPageInfo({
pageTitle: title,
pageIcon: Icon,
});
const targetWorkflowVersionId = await getUpdatableWorkflowVersion();
if (isTrigger) {
await updateOneWorkflowVersion({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
idToUpdate: targetWorkflowVersionId,
updateOneRecordInput: {
trigger: {
...stepDefinition.definition,
name: title,
} as typeof stepDefinition.definition,
},
});
} else {
await updateWorkflowVersionStep({
workflowVersionId: targetWorkflowVersionId,
step: {
...stepDefinition.definition,
name: title,
},
});
if (isDefined(agentId)) {
await updateAgentLabel(title);
}
}
};
return (
<CommandMenuPageInfoLayout
icon={
headerIcon ? (
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
) : undefined
}
iconColor={headerIconColor}
title={
<TitleInput
instanceId={`workflow-step-title-${commandMenuPageInstanceId}`}
disabled={isReadonly}
sizeVariant="sm"
value={title}
onChange={handleTitleChange}
placeholder={headerType}
onEnter={saveTitle}
onEscape={() => setEditedTitle(null)}
onClickOutside={saveTitle}
onTab={saveTitle}
onShiftTab={saveTitle}
/>
}
label={isTrigger ? t`Trigger` : t`Action`}
/>
);
};
@@ -1,64 +0,0 @@
import { CommandMenuContextRecordsChip } from '@/command-menu/components/CommandMenuContextRecordsChip';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuPreviousComponentInstanceId';
import { RESET_CONTEXT_TO_SELECTION } from '@/command-menu/constants/ResetContextToSelection';
import { useResetPreviousCommandMenuContext } from '@/command-menu/hooks/useResetPreviousCommandMenuContext';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconArrowBackUp } from 'twenty-ui/display';
export const ResetContextToSelectionCommandButton = () => {
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
'command-menu-previous',
);
const contextStoreCurrentObjectMetadataItemId = useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
'command-menu-previous',
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const objectMetadataItem = objectMetadataItems.find(
(objectMetadataItem) =>
objectMetadataItem.id === contextStoreCurrentObjectMetadataItemId,
);
const { resetPreviousCommandMenuContext } =
useResetPreviousCommandMenuContext();
if (
!isDefined(objectMetadataItem) ||
(contextStoreTargetedRecordsRule.mode === 'selection' &&
contextStoreTargetedRecordsRule.selectedRecordIds.length === 0)
) {
return null;
}
return (
<SelectableListItem
itemId={RESET_CONTEXT_TO_SELECTION}
onEnter={resetPreviousCommandMenuContext}
>
<CommandMenuItem
id={RESET_CONTEXT_TO_SELECTION}
Icon={IconArrowBackUp}
label={t`Reset to`}
RightComponent={
<CommandMenuContextRecordsChip
objectMetadataItemId={objectMetadataItem.id}
instanceId={COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID}
/>
}
onClick={resetPreviousCommandMenuContext}
/>
</SelectableListItem>
);
};
@@ -22,12 +22,12 @@ import { sleep } from '~/utils/sleep';
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { currentUserWorkspaceState } from '@/auth/states/currentUserWorkspaceState';
import { type CommandMenu } from '@/command-menu/components/CommandMenu';
import { CommandMenuRouter } from '@/command-menu/components/CommandMenuRouter';
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { SidePanelRouter } from '@/side-panel/components/SidePanelRouter';
import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { type SidePanelRootPage } from '@/side-panel/pages/root/components/SidePanelRootPage';
import { sidePanelNavigationStackState } from '@/side-panel/states/sidePanelNavigationStackState';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
@@ -37,7 +37,7 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadat
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
import { HttpResponse, graphql } from 'msw';
import { CommandMenuPages } from 'twenty-shared/types';
import { SidePanelPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconDotsVertical } from 'twenty-ui/display';
import { JestContextStoreSetter } from '~/testing/jest/JestContextStoreSetter';
@@ -47,16 +47,16 @@ const openTimeout = 50;
const ContextStoreDecorator: Decorator = (Story) => {
return (
<RecordComponentInstanceContextsWrapper
componentInstanceId={COMMAND_MENU_COMPONENT_INSTANCE_ID}
componentInstanceId={SIDE_PANEL_COMPONENT_INSTANCE_ID}
>
<ContextStoreComponentInstanceContext.Provider
value={{ instanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID }}
value={{ instanceId: SIDE_PANEL_COMPONENT_INSTANCE_ID }}
>
<ViewComponentInstanceContext.Provider
value={{ instanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID }}
value={{ instanceId: SIDE_PANEL_COMPONENT_INSTANCE_ID }}
>
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID }}
value={{ instanceId: SIDE_PANEL_COMPONENT_INSTANCE_ID }}
>
<JestContextStoreSetter
contextStoreCurrentObjectMetadataNameSingular="company"
@@ -72,9 +72,9 @@ const ContextStoreDecorator: Decorator = (Story) => {
);
};
const meta: Meta<typeof CommandMenu> = {
const meta: Meta<typeof SidePanelRootPage> = {
title: 'Modules/CommandMenu/CommandMenu',
component: CommandMenuRouter,
component: SidePanelRouter,
decorators: [
(Story) => {
jotaiStore.set(currentWorkspaceState.atom, mockCurrentWorkspace);
@@ -86,10 +86,10 @@ const meta: Meta<typeof CommandMenu> = {
currentUserWorkspaceState.atom,
mockedUserData.currentUserWorkspace,
);
jotaiStore.set(isCommandMenuOpenedState.atom, true);
jotaiStore.set(commandMenuNavigationStackState.atom, [
jotaiStore.set(isSidePanelOpenedState.atom, true);
jotaiStore.set(sidePanelNavigationStackState.atom, [
{
page: CommandMenuPages.Root,
page: SidePanelPages.Root,
pageTitle: 'Command Menu',
pageIcon: IconDotsVertical,
pageId: '1',
@@ -128,7 +128,7 @@ const meta: Meta<typeof CommandMenu> = {
};
export default meta;
type Story = StoryObj<typeof CommandMenu>;
type Story = StoryObj<typeof SidePanelRootPage>;
export const DefaultWithoutSearch: Story = {
play: async ({ canvasElement }) => {
@@ -1,268 +0,0 @@
import { gql } from '@apollo/client';
import {
type Decorator,
type Meta,
type StoryObj,
} from '@storybook/react-vite';
import { CommandMenuContextRecordsChip } from '@/command-menu/components/CommandMenuContextRecordsChip';
import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext';
import { type RecordChipData } from '@/object-record/record-field/ui/types/RecordChipData';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { ComponentDecorator } from 'twenty-ui/testing';
import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndActionMenuWrapper';
import { mockedCompanyRecords } from '~/testing/mock-data/generated/data/companies/mock-companies-data';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
const FIND_MANY_COMPANIES = gql`
query FindManyCompanies(
$filter: CompanyFilterInput
$orderBy: [CompanyOrderByInput]
$lastCursor: String
$limit: Int
) {
companies(
filter: $filter
orderBy: $orderBy
first: $limit
after: $lastCursor
) {
edges {
node {
__typename
accountOwnerId
address {
addressStreet1
addressStreet2
addressCity
addressState
addressCountry
addressPostcode
addressLat
addressLng
}
annualRecurringRevenue {
amountMicros
currencyCode
}
createdAt
createdBy {
source
workspaceMemberId
name
context
}
deletedAt
domainName {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
employees
id
idealCustomerProfile
introVideo {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
linkedinLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
name
position
tagline
updatedAt
visaSponsorship
workPolicy
xLink {
primaryLinkUrl
primaryLinkLabel
secondaryLinks
}
}
cursor
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
totalCount
}
}
`;
const companyMockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'company',
);
const companyMock = mockedCompanyRecords[0];
const chipGeneratorPerObjectPerField: Record<
string,
Record<string, (record: ObjectRecord) => RecordChipData>
> = {
company: {
name: (record: ObjectRecord): RecordChipData => ({
recordId: record.id,
name: record.name as string,
avatarUrl: '',
avatarType: 'rounded',
isLabelIdentifier: true,
objectNameSingular: 'company',
}),
},
};
const identifierChipGeneratorPerObject: Record<
string,
(record: ObjectRecord) => RecordChipData
> = {
company: chipGeneratorPerObjectPerField.company.name,
};
const ChipGeneratorsDecorator: Decorator = (Story) => (
<PreComputedChipGeneratorsContext.Provider
value={{
chipGeneratorPerObjectPerField,
identifierChipGeneratorPerObject,
}}
>
<Story />
</PreComputedChipGeneratorsContext.Provider>
);
const createContextStoreWrapper = ({
companies,
componentInstanceId,
}: {
companies: typeof mockedCompanyRecords;
componentInstanceId: string;
}) => {
return getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [
{
request: {
query: FIND_MANY_COMPANIES,
variables: {
filter: {
id: { in: companies.map((company) => company.id) },
deletedAt: { is: 'NOT_NULL' },
},
orderBy: [{ position: 'AscNullsFirst' }],
limit: 3,
},
},
result: {
data: {
companies: {
edges: companies.slice(0, 3).map((company, index) => ({
node: company,
cursor: `cursor-${index + 1}`,
})),
pageInfo: {
hasNextPage: companies.length > 3,
hasPreviousPage: false,
startCursor: 'cursor-1',
endCursor:
companies.length > 0
? `cursor-${Math.min(companies.length, 3)}`
: null,
},
totalCount: companies.length,
},
},
},
},
],
componentInstanceId,
contextStoreCurrentObjectMetadataNameSingular:
companyMockObjectMetadataItem?.nameSingular,
contextStoreTargetedRecordsRule: {
mode: 'selection',
selectedRecordIds: companies.map((company) => company.id),
},
contextStoreNumberOfSelectedRecords: companies.length,
onInitializeJotaiStore: () => {
for (const company of companies) {
jotaiStore.set(
recordStoreFamilyState.atomFamily(company.id),
company as ObjectRecord,
);
}
},
});
};
const ContextStoreDecorator: Decorator = (Story) => {
const ContextStoreWrapper = createContextStoreWrapper({
companies: [companyMock],
componentInstanceId: '1',
});
return (
<ContextStoreWrapper>
<Story />
</ContextStoreWrapper>
);
};
const meta: Meta<typeof CommandMenuContextRecordsChip> = {
title: 'Modules/CommandMenu/CommandMenuContextRecordChip',
component: CommandMenuContextRecordsChip,
decorators: [
ContextStoreDecorator,
ChipGeneratorsDecorator,
ComponentDecorator,
],
args: {
objectMetadataItemId: companyMockObjectMetadataItem?.id,
},
};
export default meta;
type Story = StoryObj<typeof CommandMenuContextRecordsChip>;
export const Default: Story = {};
export const WithTwoCompanies: Story = {
decorators: [
(Story) => {
const twoCompaniesMock = mockedCompanyRecords.slice(0, 2);
const TwoCompaniesWrapper = createContextStoreWrapper({
companies: twoCompaniesMock,
componentInstanceId: '2',
});
return (
<TwoCompaniesWrapper>
<Story />
</TwoCompaniesWrapper>
);
},
],
};
export const WithTenCompanies: Story = {
decorators: [
(Story) => {
const tenCompaniesMock = mockedCompanyRecords.slice(0, 10);
const TenCompaniesWrapper = createContextStoreWrapper({
companies: tenCompaniesMock,
componentInstanceId: '3',
});
return (
<TenCompaniesWrapper>
<Story />
</TenCompaniesWrapper>
);
},
],
};
@@ -1,199 +0,0 @@
import { GRAPH_TYPE_INFORMATION } from '@/command-menu/pages/page-layout/constants/GraphTypeInformation';
import { getCurrentGraphTypeFromConfig } from '@/command-menu/pages/page-layout/utils/getCurrentGraphTypeFromConfig';
import { isWidgetConfigurationOfTypeGraph } from '@/command-menu/pages/page-layout/utils/isWidgetConfigurationOfTypeGraph';
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { t } from '@lingui/core/macro';
import { CommandMenuPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
IconAppWindow,
IconFrame,
IconList,
IconPlus,
type IconComponent,
} from 'twenty-ui/display';
import { ThemeContext } from 'twenty-ui/theme-constants';
import { useContext } from 'react';
type PageLayoutHeaderInfo = {
headerIcon: IconComponent | undefined;
headerIconColor: string;
headerType: string;
title: string;
isReadonly: boolean;
tab: PageLayoutTab | undefined;
widgetInEditMode: PageLayoutWidget | undefined;
};
type UsePageLayoutHeaderInfoParams = {
commandMenuPage: CommandMenuPages;
draftPageLayout: {
tabs: PageLayoutTab[];
};
pageLayoutEditingWidgetId: string | null | undefined;
openTabId: string | null | undefined;
editedTitle: string | null | undefined;
};
export const usePageLayoutHeaderInfo = ({
commandMenuPage,
draftPageLayout,
pageLayoutEditingWidgetId,
openTabId,
editedTitle,
}: UsePageLayoutHeaderInfoParams): PageLayoutHeaderInfo | null => {
const { theme } = useContext(ThemeContext);
const iconColor = theme.font.color.tertiary;
switch (commandMenuPage) {
case CommandMenuPages.PageLayoutTabSettings: {
if (!isDefined(openTabId)) {
return null;
}
const tab = draftPageLayout.tabs.find((t) => t.id === openTabId);
if (!isDefined(tab)) {
return null;
}
const title = isDefined(editedTitle)
? editedTitle
: isDefined(tab.title) && tab.title !== ''
? tab.title
: '';
return {
headerIcon: IconAppWindow,
headerIconColor: iconColor,
headerType: t`Tab`,
title,
isReadonly: false,
tab,
widgetInEditMode: undefined,
};
}
case CommandMenuPages.PageLayoutIframeSettings: {
if (!isDefined(pageLayoutEditingWidgetId)) {
return null;
}
const widgetInEditMode = draftPageLayout.tabs
.flatMap((tab) => tab.widgets)
.find((widget) => widget.id === pageLayoutEditingWidgetId);
if (!isDefined(widgetInEditMode)) {
return null;
}
const title = isDefined(editedTitle)
? editedTitle
: isDefined(widgetInEditMode.title) && widgetInEditMode.title !== ''
? widgetInEditMode.title
: '';
return {
headerIcon: IconFrame,
headerIconColor: iconColor,
headerType: t`iFrame Widget`,
title,
isReadonly: false,
tab: undefined,
widgetInEditMode,
};
}
case CommandMenuPages.PageLayoutGraphTypeSelect:
case CommandMenuPages.PageLayoutGraphFilter: {
if (!isDefined(pageLayoutEditingWidgetId)) {
return null;
}
const widgetInEditMode = draftPageLayout.tabs
.flatMap((tab) => tab.widgets)
.find((widget) => widget.id === pageLayoutEditingWidgetId);
if (!isDefined(widgetInEditMode)) {
return null;
}
if (!isWidgetConfigurationOfTypeGraph(widgetInEditMode.configuration)) {
return null;
}
const currentGraphType = getCurrentGraphTypeFromConfig(
widgetInEditMode.configuration,
);
const graphTypeInfo = GRAPH_TYPE_INFORMATION[currentGraphType];
const graphTypeLabel = t(graphTypeInfo.label);
const headerType =
commandMenuPage === CommandMenuPages.PageLayoutGraphFilter
? graphTypeLabel
: t`Chart`;
const title = isDefined(editedTitle)
? editedTitle
: isDefined(widgetInEditMode.title) && widgetInEditMode.title !== ''
? widgetInEditMode.title
: '';
return {
headerIcon: graphTypeInfo.icon,
headerIconColor: iconColor,
headerType,
title,
isReadonly: commandMenuPage === CommandMenuPages.PageLayoutGraphFilter,
tab: undefined,
widgetInEditMode,
};
}
case CommandMenuPages.PageLayoutFieldsSettings:
case CommandMenuPages.PageLayoutFieldsLayout: {
if (!isDefined(pageLayoutEditingWidgetId)) {
return null;
}
const widgetInEditMode = draftPageLayout.tabs
.flatMap((tab) => tab.widgets)
.find((widget) => widget.id === pageLayoutEditingWidgetId);
if (!isDefined(widgetInEditMode)) {
return null;
}
const title = isDefined(editedTitle)
? editedTitle
: isDefined(widgetInEditMode.title) && widgetInEditMode.title !== ''
? widgetInEditMode.title
: '';
return {
headerIcon: IconList,
headerIconColor: iconColor,
headerType: t`Fields Widget`,
title,
isReadonly: false,
tab: undefined,
widgetInEditMode,
};
}
case CommandMenuPages.PageLayoutWidgetTypeSelect: {
return {
headerIcon: IconPlus,
headerIconColor: iconColor,
headerType: '',
title: t`New widget`,
isReadonly: true,
tab: undefined,
widgetInEditMode: undefined,
};
}
default:
return null;
}
};
@@ -1,25 +0,0 @@
import { themeCssVariables } from 'twenty-ui/theme-constants';
export const COMMAND_MENU_ANIMATION_VARIANTS = {
fullScreen: {
x: '0%',
width: '100%',
height: '100%',
bottom: '0',
top: '0',
},
normal: {
x: '0%',
width: themeCssVariables.rightDrawerWidth,
height: '100%',
bottom: '0',
top: '0',
},
closed: {
x: '100%',
width: themeCssVariables.rightDrawerWidth,
height: '100%',
bottom: '0',
top: 'auto',
},
};
@@ -1 +0,0 @@
export const COMMAND_MENU_COMPONENT_INSTANCE_ID = 'command-menu';
@@ -1,2 +0,0 @@
export const COMMAND_MENU_CONTEXT_CHIP_GROUPS_DROPDOWN_ID =
'command-menu-context-chip-groups-dropdown';
@@ -1 +0,0 @@
export const COMMAND_MENU_LIST_SELECTABLE_LIST_ID = 'command-menu-list';
@@ -1,2 +0,0 @@
export const COMMAND_MENU_NAVIGATION_HISTORY_DROPDOWN_ID =
'command-menu-navigation-history-dropdown';
@@ -1,86 +0,0 @@
import { CommandMenu } from '@/command-menu/components/CommandMenu';
import { CommandMenuAIChatThreadsPage } from '@/command-menu/pages/AIChatThreads/components/CommandMenuAIChatThreadsPage';
import { CommandMenuAskAIPage } from '@/command-menu/pages/ask-ai/components/CommandMenuAskAIPage';
import { CommandMenuCalendarEventPage } from '@/command-menu/pages/calendar-event/components/CommandMenuCalendarEventPage';
import { CommandMenuFrontComponentPage } from '@/command-menu/pages/front-component/components/CommandMenuFrontComponentPage';
import { CommandMenuMessageThreadPage } from '@/command-menu/pages/message-thread/components/CommandMenuMessageThreadPage';
import { CommandMenuNavigationMenuItemEditPage } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuNavigationMenuItemEditPage';
import { CommandMenuNewSidebarItemPage } from '@/command-menu/pages/navigation-menu-item/components/CommandMenuNewSidebarItemPage';
import { CommandMenuPageLayoutChartSettings } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutChartSettings';
import { CommandMenuPageLayoutFieldsLayout } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutFieldsLayout';
import { CommandMenuPageLayoutFieldsSettings } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutFieldsSettings';
import { CommandMenuPageLayoutGraphFilter } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutGraphFilter';
import { CommandMenuPageLayoutIframeSettings } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutIframeSettings';
import { CommandMenuPageLayoutTabSettings } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutTabSettings';
import { CommandMenuPageLayoutWidgetTypeSelect } from '@/command-menu/pages/page-layout/components/CommandMenuPageLayoutWidgetTypeSelect';
import { CommandMenuMergeRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuMergeRecordPage';
import { CommandMenuRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuRecordPage';
import { CommandMenuUpdateMultipleRecords } from '@/command-menu/pages/record-page/components/CommandMenuUpdateMultipleRecords';
import { CommandMenuEditRichTextPage } from '@/command-menu/pages/rich-text-page/components/CommandMenuEditRichTextPage';
import { CommandMenuSearchRecordsPage } from '@/command-menu/pages/search/components/CommandMenuSearchRecordsPage';
import { CommandMenuWorkflowCreateStep } from '@/command-menu/pages/workflow/step/create/components/CommandMenuWorkflowCreateStep';
import { CommandMenuWorkflowEditStep } from '@/command-menu/pages/workflow/step/edit/components/CommandMenuWorkflowEditStep';
import { CommandMenuWorkflowEditStepType } from '@/command-menu/pages/workflow/step/edit/components/CommandMenuWorkflowEditStepType';
import { CommandMenuWorkflowRunViewStep } from '@/command-menu/pages/workflow/step/view-run/components/CommandMenuWorkflowRunViewStep';
import { CommandMenuWorkflowViewStep } from '@/command-menu/pages/workflow/step/view/components/CommandMenuWorkflowViewStep';
import { CommandMenuWorkflowSelectTriggerType } from '@/command-menu/pages/workflow/trigger-type/components/CommandMenuWorkflowSelectTriggerType';
import { CommandMenuPages } from 'twenty-shared/types';
export const COMMAND_MENU_PAGES_CONFIG = new Map<
CommandMenuPages,
React.ReactNode
>([
[CommandMenuPages.Root, <CommandMenu />],
[CommandMenuPages.ViewRecord, <CommandMenuRecordPage />],
[CommandMenuPages.MergeRecords, <CommandMenuMergeRecordPage />],
[CommandMenuPages.UpdateRecords, <CommandMenuUpdateMultipleRecords />],
[CommandMenuPages.ViewEmailThread, <CommandMenuMessageThreadPage />],
[CommandMenuPages.ViewCalendarEvent, <CommandMenuCalendarEventPage />],
[CommandMenuPages.EditRichText, <CommandMenuEditRichTextPage />],
[
CommandMenuPages.WorkflowTriggerSelectType,
<CommandMenuWorkflowSelectTriggerType />,
],
[CommandMenuPages.WorkflowStepCreate, <CommandMenuWorkflowCreateStep />],
[CommandMenuPages.WorkflowStepEditType, <CommandMenuWorkflowEditStepType />],
[CommandMenuPages.WorkflowStepEdit, <CommandMenuWorkflowEditStep />],
[CommandMenuPages.WorkflowStepView, <CommandMenuWorkflowViewStep />],
[CommandMenuPages.WorkflowRunStepView, <CommandMenuWorkflowRunViewStep />],
[CommandMenuPages.SearchRecords, <CommandMenuSearchRecordsPage />],
[CommandMenuPages.AskAI, <CommandMenuAskAIPage />],
[CommandMenuPages.ViewPreviousAIChats, <CommandMenuAIChatThreadsPage />],
[
CommandMenuPages.PageLayoutWidgetTypeSelect,
<CommandMenuPageLayoutWidgetTypeSelect />,
],
[
CommandMenuPages.PageLayoutGraphTypeSelect,
<CommandMenuPageLayoutChartSettings />,
],
[
CommandMenuPages.PageLayoutGraphFilter,
<CommandMenuPageLayoutGraphFilter />,
],
[
CommandMenuPages.PageLayoutIframeSettings,
<CommandMenuPageLayoutIframeSettings />,
],
[
CommandMenuPages.PageLayoutTabSettings,
<CommandMenuPageLayoutTabSettings />,
],
[
CommandMenuPages.PageLayoutFieldsSettings,
<CommandMenuPageLayoutFieldsSettings />,
],
[
CommandMenuPages.PageLayoutFieldsLayout,
<CommandMenuPageLayoutFieldsLayout />,
],
[CommandMenuPages.ViewFrontComponent, <CommandMenuFrontComponentPage />],
[
CommandMenuPages.NavigationMenuItemEdit,
<CommandMenuNavigationMenuItemEditPage />,
],
[CommandMenuPages.NavigationMenuAddItem, <CommandMenuNewSidebarItemPage />],
]);
@@ -1,2 +0,0 @@
export const COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID =
'command-menu-previous';
@@ -1 +0,0 @@
export const COMMAND_MENU_SEARCH_BAR_HEIGHT = 40;
@@ -1 +0,0 @@
export const COMMAND_MENU_SEARCH_BAR_HEIGHT_MOBILE = 52;
@@ -1 +0,0 @@
export const COMMAND_MENU_SEARCH_BAR_PADDING = 2;
@@ -1 +0,0 @@
export const RESET_CONTEXT_TO_SELECTION = 'reset-context-to-selection';
@@ -1 +0,0 @@
export const SIDE_PANEL_FOCUS_ID = 'command-menu';
@@ -3,13 +3,13 @@ import { Provider as JotaiProvider } from 'jotai';
import { act } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { sidePanelNavigationStackState } from '@/side-panel/states/sidePanelNavigationStackState';
import { sidePanelPageInfoState } from '@/side-panel/states/sidePanelPageInfoState';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { CommandMenuPages } from 'twenty-shared/types';
import { SidePanelPages } from 'twenty-shared/types';
import { IconDotsVertical } from 'twenty-ui/display';
const Wrapper = ({ children }: { children: React.ReactNode }) => (
@@ -26,7 +26,7 @@ const Wrapper = ({ children }: { children: React.ReactNode }) => (
const renderHooks = () => {
const { result } = renderHook(
() => {
const commandMenu = useCommandMenu();
const commandMenu = useSidePanelMenu();
return {
commandMenu,
@@ -39,7 +39,7 @@ const renderHooks = () => {
return { result };
};
describe('useCommandMenu', () => {
describe('useSidePanelMenu', () => {
beforeEach(() => {
jest.clearAllMocks();
});
@@ -48,88 +48,78 @@ describe('useCommandMenu', () => {
const { result } = renderHooks();
act(() => {
result.current.commandMenu.openCommandMenu();
result.current.commandMenu.openSidePanelMenu();
});
expect(jotaiStore.get(isCommandMenuOpenedState.atom)).toBe(true);
expect(jotaiStore.get(isSidePanelOpenedState.atom)).toBe(true);
act(() => {
result.current.commandMenu.closeCommandMenu();
result.current.commandMenu.closeSidePanelMenu();
});
expect(jotaiStore.get(isCommandMenuOpenedState.atom)).toBe(false);
expect(jotaiStore.get(isSidePanelOpenedState.atom)).toBe(false);
});
it('should toggle the command menu', () => {
const { result } = renderHooks();
expect(jotaiStore.get(isCommandMenuOpenedState.atom)).toBe(false);
expect(jotaiStore.get(isSidePanelOpenedState.atom)).toBe(false);
act(() => {
result.current.commandMenu.toggleCommandMenu();
result.current.commandMenu.toggleSidePanelMenu();
});
expect(jotaiStore.get(isCommandMenuOpenedState.atom)).toBe(true);
expect(jotaiStore.get(isSidePanelOpenedState.atom)).toBe(true);
act(() => {
result.current.commandMenu.toggleCommandMenu();
result.current.commandMenu.toggleSidePanelMenu();
});
expect(jotaiStore.get(isCommandMenuOpenedState.atom)).toBe(false);
expect(jotaiStore.get(isSidePanelOpenedState.atom)).toBe(false);
});
it('should navigate command menu and reset navigation stack when resetNavigationStack is true', () => {
const { result } = renderHooks();
act(() => {
result.current.commandMenu.navigateCommandMenu({
page: CommandMenuPages.Root,
result.current.commandMenu.navigateSidePanelMenu({
page: SidePanelPages.Root,
pageTitle: 'First Page',
pageIcon: IconDotsVertical,
resetNavigationStack: false,
});
});
expect(jotaiStore.get(commandMenuPageState.atom)).toBe(
CommandMenuPages.Root,
);
expect(jotaiStore.get(commandMenuPageInfoState.atom).title).toBe(
expect(jotaiStore.get(sidePanelPageState.atom)).toBe(SidePanelPages.Root);
expect(jotaiStore.get(sidePanelPageInfoState.atom).title).toBe(
'First Page',
);
expect(jotaiStore.get(commandMenuNavigationStackState.atom)).toHaveLength(
1,
);
expect(jotaiStore.get(sidePanelNavigationStackState.atom)).toHaveLength(1);
act(() => {
result.current.commandMenu.navigateCommandMenu({
page: CommandMenuPages.SearchRecords,
result.current.commandMenu.navigateSidePanelMenu({
page: SidePanelPages.SearchRecords,
pageTitle: 'Second Page',
pageIcon: IconDotsVertical,
resetNavigationStack: false,
});
});
expect(jotaiStore.get(commandMenuNavigationStackState.atom)).toHaveLength(
2,
);
expect(jotaiStore.get(sidePanelNavigationStackState.atom)).toHaveLength(2);
act(() => {
result.current.commandMenu.navigateCommandMenu({
page: CommandMenuPages.Root,
result.current.commandMenu.navigateSidePanelMenu({
page: SidePanelPages.Root,
pageTitle: 'Reset Page',
pageIcon: IconDotsVertical,
resetNavigationStack: true,
});
});
expect(jotaiStore.get(commandMenuPageState.atom)).toBe(
CommandMenuPages.Root,
);
expect(jotaiStore.get(commandMenuPageInfoState.atom).title).toBe(
expect(jotaiStore.get(sidePanelPageState.atom)).toBe(SidePanelPages.Root);
expect(jotaiStore.get(sidePanelPageInfoState.atom).title).toBe(
'Reset Page',
);
expect(jotaiStore.get(commandMenuNavigationStackState.atom)).toHaveLength(
1,
);
expect(jotaiStore.get(sidePanelNavigationStackState.atom)).toHaveLength(1);
});
});
@@ -1,177 +0,0 @@
import { renderHook } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { act } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
import { COMMAND_MENU_CONTEXT_CHIP_GROUPS_DROPDOWN_ID } from '@/command-menu/constants/CommandMenuContextChipGroupsDropdownId';
import { COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuPreviousComponentInstanceId';
import { useCommandMenuCloseAnimationCompleteCleanup } from '@/command-menu/hooks/useCommandMenuCloseAnimationCompleteCleanup';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { hasUserSelectedCommandState } from '@/command-menu/states/hasUserSelectedCommandState';
import { isCommandMenuClosingState } from '@/command-menu/states/isCommandMenuClosingState';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomState } from '@/ui/utilities/state/jotai/hooks/useSetAtomState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { CommandMenuPages } from 'twenty-shared/types';
import { IconList } from 'twenty-ui/display';
const mockCloseDropdown = jest.fn();
const mockResetContextStoreStates = jest.fn();
const mockResetSelectedItem = jest.fn();
const mockEmitSidePanelCloseEvent = jest.fn();
jest.mock('@/ui/layout/dropdown/hooks/useCloseDropdown', () => ({
useCloseDropdown: () => ({
closeDropdown: mockCloseDropdown,
}),
}));
jest.mock('@/command-menu/hooks/useResetContextStoreStates', () => ({
useResetContextStoreStates: () => ({
resetContextStoreStates: mockResetContextStoreStates,
}),
}));
jest.mock('@/ui/layout/selectable-list/hooks/useSelectableList', () => ({
useSelectableList: () => ({
resetSelectedItem: mockResetSelectedItem,
}),
}));
jest.mock('@/ui/layout/right-drawer/utils/emitSidePanelCloseEvent', () => ({
emitSidePanelCloseEvent: () => {
mockEmitSidePanelCloseEvent();
},
}));
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<JotaiProvider store={jotaiStore}>
<MemoryRouter>{children}</MemoryRouter>
</JotaiProvider>
);
describe('useCommandMenuCloseAnimationCompleteCleanup', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const renderHooks = () => {
const { result } = renderHook(
() => {
const { commandMenuCloseAnimationCompleteCleanup } =
useCommandMenuCloseAnimationCompleteCleanup();
const viewableRecordId = useAtomStateValue(viewableRecordIdState);
const setViewableRecordId = useSetAtomState(viewableRecordIdState);
return {
commandMenuCloseAnimationCompleteCleanup,
viewableRecordId,
setViewableRecordId,
};
},
{
wrapper: Wrapper,
},
);
return { result };
};
it('should reset modified states back to default values', () => {
const { result } = renderHooks();
act(() => {
jotaiStore.set(commandMenuPageState.atom, CommandMenuPages.ViewRecord);
jotaiStore.set(commandMenuPageInfoState.atom, {
title: 'Test Record',
Icon: IconList,
instanceId: 'test-id',
});
jotaiStore.set(isCommandMenuOpenedState.atom, true);
jotaiStore.set(commandMenuSearchState.atom, 'test search');
jotaiStore.set(commandMenuNavigationStackState.atom, [
{
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconList,
pageId: '1',
},
]);
jotaiStore.set(hasUserSelectedCommandState.atom, true);
jotaiStore.set(isCommandMenuClosingState.atom, true);
result.current.setViewableRecordId('record-123');
});
expect(jotaiStore.get(commandMenuPageState.atom)).toBe(
CommandMenuPages.ViewRecord,
);
expect(jotaiStore.get(commandMenuPageInfoState.atom)).toEqual({
title: 'Test Record',
Icon: IconList,
instanceId: 'test-id',
});
expect(jotaiStore.get(isCommandMenuOpenedState.atom)).toBe(true);
expect(jotaiStore.get(commandMenuSearchState.atom)).toBe('test search');
expect(jotaiStore.get(commandMenuNavigationStackState.atom)).toEqual([
{
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconList,
pageId: '1',
},
]);
expect(jotaiStore.get(hasUserSelectedCommandState.atom)).toBe(true);
expect(jotaiStore.get(isCommandMenuClosingState.atom)).toBe(true);
expect(result.current.viewableRecordId).toBe('record-123');
act(() => {
result.current.commandMenuCloseAnimationCompleteCleanup();
});
expect(jotaiStore.get(commandMenuPageState.atom)).toBe(
CommandMenuPages.Root,
);
expect(jotaiStore.get(commandMenuPageInfoState.atom)).toEqual({
title: undefined,
Icon: undefined,
instanceId: '',
});
expect(jotaiStore.get(isCommandMenuOpenedState.atom)).toBe(false);
expect(jotaiStore.get(commandMenuSearchState.atom)).toBe('');
expect(jotaiStore.get(commandMenuNavigationStackState.atom)).toEqual([]);
expect(jotaiStore.get(hasUserSelectedCommandState.atom)).toBe(false);
expect(jotaiStore.get(isCommandMenuClosingState.atom)).toBe(false);
expect(result.current.viewableRecordId).toBe(null);
});
it('should call all dependent functions correctly', () => {
const { result } = renderHooks();
act(() => {
result.current.commandMenuCloseAnimationCompleteCleanup();
});
expect(mockCloseDropdown).toHaveBeenCalledTimes(1);
expect(mockResetContextStoreStates).toHaveBeenCalledTimes(2);
expect(mockResetSelectedItem).toHaveBeenCalledTimes(1);
expect(mockEmitSidePanelCloseEvent).toHaveBeenCalledTimes(1);
expect(mockCloseDropdown).toHaveBeenCalledWith(
COMMAND_MENU_CONTEXT_CHIP_GROUPS_DROPDOWN_ID,
);
expect(mockResetContextStoreStates).toHaveBeenNthCalledWith(
1,
COMMAND_MENU_COMPONENT_INSTANCE_ID,
);
expect(mockResetContextStoreStates).toHaveBeenNthCalledWith(
2,
COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID,
);
});
});
@@ -1,143 +0,0 @@
import { renderHook } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { act } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useCommandMenuCloseAnimationCompleteCleanup } from '@/command-menu/hooks/useCommandMenuCloseAnimationCompleteCleanup';
import { useCommandMenuHistory } from '@/command-menu/hooks/useCommandMenuHistory';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { CommandMenuPages } from 'twenty-shared/types';
import { IconList, IconSearch } from 'twenty-ui/display';
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<JotaiProvider store={jotaiStore}>
<MemoryRouter>{children}</MemoryRouter>
</JotaiProvider>
);
const renderHooks = () => {
const { result } = renderHook(
() => {
const commandMenu = useCommandMenu();
const commandMenuHistory = useCommandMenuHistory();
const commandMenuCloseAnimationCompleteCleanup =
useCommandMenuCloseAnimationCompleteCleanup();
return {
commandMenu,
commandMenuHistory,
commandMenuCloseAnimationCompleteCleanup,
};
},
{
wrapper: Wrapper,
},
);
return { result };
};
describe('useCommandMenuHistory', () => {
it('should go back from a page', () => {
const { result } = renderHooks();
act(() => {
result.current.commandMenu.navigateCommandMenu({
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
pageId: '1',
});
});
act(() => {
result.current.commandMenu.navigateCommandMenu({
page: CommandMenuPages.ViewRecord,
pageTitle: 'Company',
pageIcon: IconList,
pageId: '2',
});
});
expect(jotaiStore.get(commandMenuNavigationStackState.atom)).toEqual([
{
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
pageId: '1',
},
{
page: CommandMenuPages.ViewRecord,
pageTitle: 'Company',
pageIcon: IconList,
pageId: '2',
},
]);
act(() => {
result.current.commandMenuHistory.goBackFromCommandMenu();
});
expect(jotaiStore.get(commandMenuNavigationStackState.atom)).toEqual([
{
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
pageId: '1',
},
]);
expect(jotaiStore.get(commandMenuPageState.atom)).toBe(
CommandMenuPages.SearchRecords,
);
expect(jotaiStore.get(commandMenuPageInfoState.atom)).toEqual({
title: 'Search',
Icon: IconSearch,
instanceId: '1',
});
act(() => {
result.current.commandMenuHistory.goBackFromCommandMenu();
result.current.commandMenuCloseAnimationCompleteCleanup.commandMenuCloseAnimationCompleteCleanup();
});
expect(jotaiStore.get(commandMenuNavigationStackState.atom)).toEqual([]);
expect(jotaiStore.get(commandMenuPageState.atom)).toBe(
CommandMenuPages.Root,
);
expect(jotaiStore.get(commandMenuPageInfoState.atom)).toEqual({
title: undefined,
instanceId: '',
Icon: undefined,
});
expect(jotaiStore.get(isCommandMenuOpenedState.atom)).toBe(false);
});
it('should navigate to a page in history', () => {
const { result } = renderHooks();
act(() => {
result.current.commandMenu.navigateCommandMenu({
page: CommandMenuPages.SearchRecords,
pageTitle: 'Search',
pageIcon: IconSearch,
pageId: '1',
});
});
act(() => {
result.current.commandMenuHistory.navigateCommandMenuHistory(0);
});
expect(jotaiStore.get(commandMenuPageState.atom)).toBe(
CommandMenuPages.SearchRecords,
);
expect(jotaiStore.get(commandMenuPageInfoState.atom)).toEqual({
title: 'Search',
Icon: IconSearch,
instanceId: '1',
});
});
});
@@ -1,102 +0,0 @@
import { useCommandMenuUpdateNavigationMorphItemsByPage } from '@/command-menu/hooks/useCommandMenuUpdateNavigationMorphItemsByPage';
import { commandMenuNavigationMorphItemsByPageState } from '@/command-menu/states/commandMenuNavigationMorphItemsByPageState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { renderHook } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { act } from 'react';
const pageId = 'merge-page-id';
const objectMetadataId = 'company-metadata-id';
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
);
const renderHooks = (initialRecordIds: string[]) => {
jotaiStore.set(
commandMenuNavigationMorphItemsByPageState.atom,
new Map([
[
pageId,
initialRecordIds.map((recordId) => ({
objectMetadataId,
recordId,
})),
],
]),
);
return renderHook(
() => {
const { updateCommandMenuNavigationMorphItemsByPage } =
useCommandMenuUpdateNavigationMorphItemsByPage();
return {
updateCommandMenuNavigationMorphItemsByPage,
};
},
{
wrapper: Wrapper,
},
);
};
describe('useCommandMenuUpdateNavigationMorphItemsByPage', () => {
it('should replace existing items for a page instead of appending', async () => {
const { result } = renderHooks(['record-1', 'record-2']);
await act(async () => {
await result.current.updateCommandMenuNavigationMorphItemsByPage({
pageId,
objectMetadataId,
objectRecordIds: ['record-2', 'record-1'],
});
});
expect(
jotaiStore
.get(commandMenuNavigationMorphItemsByPageState.atom)
.get(pageId),
).toEqual([
{
objectMetadataId,
recordId: 'record-2',
},
{
objectMetadataId,
recordId: 'record-1',
},
]);
});
it('should keep only the latest payload when called twice for the same page', async () => {
const { result } = renderHooks([]);
await act(async () => {
await result.current.updateCommandMenuNavigationMorphItemsByPage({
pageId,
objectMetadataId,
objectRecordIds: ['record-1', 'record-2'],
});
await result.current.updateCommandMenuNavigationMorphItemsByPage({
pageId,
objectMetadataId,
objectRecordIds: ['record-2', 'record-1'],
});
});
expect(
jotaiStore
.get(commandMenuNavigationMorphItemsByPageState.atom)
.get(pageId),
).toEqual([
{
objectMetadataId,
recordId: 'record-2',
},
{
objectMetadataId,
recordId: 'record-1',
},
]);
});
});
@@ -1,102 +0,0 @@
import { type ActionConfig } from '@/action-menu/actions/types/ActionConfig';
import { ActionScope } from '@/action-menu/actions/types/ActionScope';
import { ActionType } from '@/action-menu/actions/types/ActionType';
import { renderHook } from '@testing-library/react';
import { IconPlus } from 'twenty-ui/display';
import { useFilterActionsWithCommandMenuSearch } from '@/command-menu/hooks/useFilterActionsWithCommandMenuSearch';
const MockComponent = <div>Mock Component</div>;
describe('useFilterActionsWithCommandMenuSearch', () => {
const mockActions: ActionConfig[] = [
{
key: 'action-1',
label: 'Create Record',
type: ActionType.Standard,
scope: ActionScope.Global,
position: 1,
Icon: IconPlus,
shouldBeRegistered: () => true,
component: MockComponent,
hotKeys: ['c', 'r'],
},
{
key: 'action-2',
label: 'Delete Record',
type: ActionType.Standard,
scope: ActionScope.RecordSelection,
position: 2,
Icon: IconPlus,
shouldBeRegistered: () => true,
component: MockComponent,
hotKeys: ['d', 'e', 'l'],
},
{
key: 'action-3',
label: 'Update Record',
type: ActionType.Standard,
scope: ActionScope.Object,
position: 3,
Icon: IconPlus,
shouldBeRegistered: () => true,
component: MockComponent,
},
];
it('should return all actions when search is empty', () => {
const { result } = renderHook(() =>
useFilterActionsWithCommandMenuSearch({ commandMenuSearch: '' }),
);
const filtered =
result.current.filterActionsWithCommandMenuSearch(mockActions);
expect(filtered).toEqual(mockActions);
});
it('should filter actions by label', () => {
const { result } = renderHook(() =>
useFilterActionsWithCommandMenuSearch({ commandMenuSearch: 'Create' }),
);
const filtered =
result.current.filterActionsWithCommandMenuSearch(mockActions);
expect(filtered).toHaveLength(1);
expect(filtered[0].key).toBe('action-1');
});
it('should filter actions by hotkeys', () => {
const { result } = renderHook(() =>
useFilterActionsWithCommandMenuSearch({ commandMenuSearch: 'del' }),
);
const filtered =
result.current.filterActionsWithCommandMenuSearch(mockActions);
expect(filtered).toHaveLength(1);
expect(filtered[0].key).toBe('action-2');
});
it('should return empty array when no actions match', () => {
const { result } = renderHook(() =>
useFilterActionsWithCommandMenuSearch({ commandMenuSearch: 'xyz' }),
);
const filtered =
result.current.filterActionsWithCommandMenuSearch(mockActions);
expect(filtered).toEqual([]);
});
it('should match actions by either label or hotkeys', () => {
const { result } = renderHook(() =>
useFilterActionsWithCommandMenuSearch({ commandMenuSearch: 'Record' }),
);
const filtered =
result.current.filterActionsWithCommandMenuSearch(mockActions);
expect(filtered).toHaveLength(3);
});
});
@@ -1,94 +0,0 @@
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { CommandMenuPages } from 'twenty-shared/types';
import { Icon123, useIcons } from 'twenty-ui/display';
import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndActionMenuWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('mocked-uuid'),
}));
const personMockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
)!;
const wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
componentInstanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID,
contextStoreCurrentObjectMetadataNameSingular:
personMockObjectMetadataItem.nameSingular,
contextStoreCurrentViewId: 'my-view-id',
contextStoreTargetedRecordsRule: {
mode: 'selection',
selectedRecordIds: [],
},
contextStoreNumberOfSelectedRecords: 0,
contextStoreCurrentViewType: ContextStoreViewType.Table,
});
const renderHooks = () => {
const { result } = renderHook(
() => {
const { navigateCommandMenu } = useNavigateCommandMenu();
const { getIcon } = useIcons();
return {
navigateCommandMenu,
getIcon,
};
},
{
wrapper,
},
);
return { result };
};
describe('useNavigateCommandMenu', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should navigate to the correct page', () => {
const { result } = renderHooks();
act(() => {
result.current.navigateCommandMenu({
page: CommandMenuPages.Root,
pageTitle: 'Root',
pageIcon: Icon123,
pageIconColor: 'red',
pageId: 'mocked-uuid',
resetNavigationStack: false,
});
});
expect(jotaiStore.get(commandMenuPageState.atom)).toBe(
CommandMenuPages.Root,
);
expect(jotaiStore.get(commandMenuNavigationStackState.atom)).toEqual([
{
page: CommandMenuPages.Root,
pageTitle: 'Root',
pageIcon: Icon123,
pageIconColor: 'red',
pageId: 'mocked-uuid',
},
]);
expect(jotaiStore.get(commandMenuPageInfoState.atom)).toEqual({
title: 'Root',
Icon: Icon123,
instanceId: 'mocked-uuid',
});
});
});
@@ -1,85 +0,0 @@
import { act, renderHook } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { type ReactNode } from 'react';
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { CommandMenuPages } from 'twenty-shared/types';
import { IconSparkles } from 'twenty-ui/display';
const navigateCommandMenuMock = jest.fn();
jest.mock('@/command-menu/hooks/useCommandMenu', () => ({
useCommandMenu: () => ({
navigateCommandMenu: navigateCommandMenuMock,
openCommandMenu: jest.fn(),
closeCommandMenu: jest.fn(),
toggleCommandMenu: jest.fn(),
}),
}));
const Wrapper = ({ children }: { children: ReactNode }) => (
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
);
describe('useOpenAskAIPageInCommandMenu', () => {
beforeEach(() => {
jest.clearAllMocks();
jotaiStore.set(isCommandMenuOpenedState.atom, false);
});
it('should navigate to AskAI page with correct defaults', () => {
const { result } = renderHook(() => useOpenAskAIPageInCommandMenu(), {
wrapper: Wrapper,
});
act(() => {
result.current.openAskAIPage();
});
expect(navigateCommandMenuMock).toHaveBeenCalledWith(
expect.objectContaining({
page: CommandMenuPages.AskAI,
pageTitle: 'Ask AI',
pageIcon: IconSparkles,
}),
);
});
it('should use resetNavigationStack from argument when provided', () => {
jotaiStore.set(isCommandMenuOpenedState.atom, true);
const { result } = renderHook(() => useOpenAskAIPageInCommandMenu(), {
wrapper: Wrapper,
});
act(() => {
result.current.openAskAIPage({ resetNavigationStack: false });
});
expect(navigateCommandMenuMock).toHaveBeenCalledWith(
expect.objectContaining({
resetNavigationStack: false,
}),
);
});
it('should default resetNavigationStack to isCommandMenuOpened', () => {
jotaiStore.set(isCommandMenuOpenedState.atom, true);
const { result } = renderHook(() => useOpenAskAIPageInCommandMenu(), {
wrapper: Wrapper,
});
act(() => {
result.current.openAskAIPage();
});
expect(navigateCommandMenuMock).toHaveBeenCalledWith(
expect.objectContaining({
resetNavigationStack: true,
}),
);
});
});
@@ -1,89 +0,0 @@
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
import { useOpenCalendarEventInCommandMenu } from '@/command-menu/hooks/useOpenCalendarEventInCommandMenu';
import { viewableRecordIdComponentState } from '@/command-menu/pages/record-page/states/viewableRecordIdComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { CommandMenuPages } from 'twenty-shared/types';
import { IconCalendarEvent } from 'twenty-ui/display';
import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndActionMenuWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('mocked-uuid'),
}));
const mockNavigateCommandMenu = jest.fn();
jest.mock('@/command-menu/hooks/useNavigateCommandMenu', () => ({
useNavigateCommandMenu: () => ({
navigateCommandMenu: mockNavigateCommandMenu,
}),
}));
const personMockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
)!;
const wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
componentInstanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID,
contextStoreCurrentObjectMetadataNameSingular:
personMockObjectMetadataItem.nameSingular,
contextStoreCurrentViewId: 'my-view-id',
contextStoreTargetedRecordsRule: {
mode: 'selection',
selectedRecordIds: [],
},
contextStoreNumberOfSelectedRecords: 0,
contextStoreCurrentViewType: ContextStoreViewType.Table,
});
const renderHooks = () => {
const { result } = renderHook(
() => {
const { openCalendarEventInCommandMenu } =
useOpenCalendarEventInCommandMenu();
const viewableRecordId = useAtomComponentStateValue(
viewableRecordIdComponentState,
'mocked-uuid',
);
return {
openCalendarEventInCommandMenu,
viewableRecordId,
};
},
{
wrapper,
},
);
return { result };
};
describe('useOpenCalendarEventInCommandMenu', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should set the correct states and navigate to the calendar event page', () => {
const { result } = renderHooks();
const calendarEventId = 'calendar-event-123';
act(() => {
result.current.openCalendarEventInCommandMenu(calendarEventId);
});
expect(result.current.viewableRecordId).toBe(calendarEventId);
expect(mockNavigateCommandMenu).toHaveBeenCalledWith({
page: CommandMenuPages.ViewCalendarEvent,
pageTitle: 'Calendar Event',
pageIcon: IconCalendarEvent,
pageId: 'mocked-uuid',
});
});
});
@@ -1,89 +0,0 @@
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
import { useOpenEmailThreadInCommandMenu } from '@/command-menu/hooks/useOpenEmailThreadInCommandMenu';
import { viewableRecordIdComponentState } from '@/command-menu/pages/record-page/states/viewableRecordIdComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { CommandMenuPages } from 'twenty-shared/types';
import { IconMail } from 'twenty-ui/display';
import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndActionMenuWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('mocked-uuid'),
}));
const mockNavigateCommandMenu = jest.fn();
jest.mock('@/command-menu/hooks/useNavigateCommandMenu', () => ({
useNavigateCommandMenu: () => ({
navigateCommandMenu: mockNavigateCommandMenu,
}),
}));
const personMockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
)!;
const wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
componentInstanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID,
contextStoreCurrentObjectMetadataNameSingular:
personMockObjectMetadataItem.nameSingular,
contextStoreCurrentViewId: 'my-view-id',
contextStoreTargetedRecordsRule: {
mode: 'selection',
selectedRecordIds: [],
},
contextStoreNumberOfSelectedRecords: 0,
contextStoreCurrentViewType: ContextStoreViewType.Table,
});
const renderHooks = () => {
const { result } = renderHook(
() => {
const { openEmailThreadInCommandMenu } =
useOpenEmailThreadInCommandMenu();
const viewableRecordId = useAtomComponentStateValue(
viewableRecordIdComponentState,
'mocked-uuid',
);
return {
openEmailThreadInCommandMenu,
viewableRecordId,
};
},
{
wrapper,
},
);
return { result };
};
describe('useOpenEmailThreadInCommandMenu', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should set the correct states and navigate to the email thread page', () => {
const { result } = renderHooks();
const emailThreadId = 'email-thread-123';
act(() => {
result.current.openEmailThreadInCommandMenu(emailThreadId);
});
expect(result.current.viewableRecordId).toBe(emailThreadId);
expect(mockNavigateCommandMenu).toHaveBeenCalledWith({
page: CommandMenuPages.ViewEmailThread,
pageTitle: 'Email Thread',
pageIcon: IconMail,
pageId: 'mocked-uuid',
});
});
});
@@ -1,219 +0,0 @@
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordInCommandMenu';
import { viewableRecordIdComponentState } from '@/command-menu/pages/record-page/states/viewableRecordIdComponentState';
import { viewableRecordNameSingularComponentState } from '@/command-menu/pages/record-page/states/viewableRecordNameSingularComponentState';
import { commandMenuNavigationMorphItemsByPageState } from '@/command-menu/states/commandMenuNavigationMorphItemsByPageState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { CommandMenuPages } from 'twenty-shared/types';
import { useIcons } from 'twenty-ui/display';
import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndActionMenuWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
jest.mock('uuid', () => ({
v4: jest.fn().mockReturnValue('mocked-uuid'),
}));
const mockNavigateCommandMenu = jest.fn();
jest.mock('@/command-menu/hooks/useNavigateCommandMenu', () => ({
useNavigateCommandMenu: () => ({
navigateCommandMenu: mockNavigateCommandMenu,
}),
}));
const mockOpenNewRecordTitleCell = jest.fn();
jest.mock(
'@/object-record/record-title-cell/hooks/useOpenNewRecordTitleCell',
() => ({
useOpenNewRecordTitleCell: () => ({
openNewRecordTitleCell: mockOpenNewRecordTitleCell,
}),
}),
);
const personMockObjectMetadataItem = generatedMockObjectMetadataItems.find(
(item) => item.nameSingular === 'person',
)!;
const wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
componentInstanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID,
contextStoreCurrentObjectMetadataNameSingular:
personMockObjectMetadataItem.nameSingular,
contextStoreCurrentViewId: 'my-view-id',
contextStoreTargetedRecordsRule: {
mode: 'selection',
selectedRecordIds: [],
},
contextStoreNumberOfSelectedRecords: 0,
contextStoreCurrentViewType: ContextStoreViewType.Table,
});
const renderHooks = () => {
const { result } = renderHook(
() => {
const { openRecordInCommandMenu } = useOpenRecordInCommandMenu();
const viewableRecordId = useAtomComponentStateValue(
viewableRecordIdComponentState,
'mocked-uuid',
);
const viewableRecordNameSingular = useAtomComponentStateValue(
viewableRecordNameSingularComponentState,
'mocked-uuid',
);
const contextStoreCurrentObjectMetadataItemId =
useAtomComponentStateValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
'mocked-uuid',
);
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
'mocked-uuid',
);
const contextStoreNumberOfSelectedRecords = useAtomComponentStateValue(
contextStoreNumberOfSelectedRecordsComponentState,
'mocked-uuid',
);
const contextStoreCurrentViewType = useAtomComponentStateValue(
contextStoreCurrentViewTypeComponentState,
'mocked-uuid',
);
const { getIcon } = useIcons();
return {
openRecordInCommandMenu,
viewableRecordId,
viewableRecordNameSingular,
contextStoreCurrentObjectMetadataItemId,
contextStoreTargetedRecordsRule,
contextStoreNumberOfSelectedRecords,
contextStoreCurrentViewType,
getIcon,
};
},
{
wrapper,
},
);
return { result };
};
describe('useOpenRecordInCommandMenu', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should set the correct states and navigate to the record page', () => {
const { result } = renderHooks();
const recordId = 'record-123';
const objectNameSingular = 'person';
act(() => {
result.current.openRecordInCommandMenu({
recordId,
objectNameSingular,
});
});
expect(result.current.viewableRecordId).toBe(recordId);
expect(result.current.viewableRecordNameSingular).toBe(objectNameSingular);
expect(result.current.contextStoreCurrentObjectMetadataItemId).toBe(
personMockObjectMetadataItem.id,
);
expect(result.current.contextStoreTargetedRecordsRule).toEqual({
mode: 'selection',
selectedRecordIds: [recordId],
});
expect(result.current.contextStoreNumberOfSelectedRecords).toBe(1);
expect(result.current.contextStoreCurrentViewType).toBe(
ContextStoreViewType.ShowPage,
);
const commandMenuNavigationMorphItemsByPage = jotaiStore.get(
commandMenuNavigationMorphItemsByPageState.atom,
);
expect(commandMenuNavigationMorphItemsByPage.size).toBe(1);
expect(commandMenuNavigationMorphItemsByPage.get('mocked-uuid')).toEqual([
{
objectMetadataId: personMockObjectMetadataItem.id,
recordId,
},
]);
expect(mockNavigateCommandMenu).toHaveBeenCalledWith({
page: CommandMenuPages.ViewRecord,
pageTitle: 'Person',
pageIcon: result.current.getIcon(personMockObjectMetadataItem.icon),
pageIconColor: 'currentColor',
pageId: 'mocked-uuid',
resetNavigationStack: false,
});
});
it('should set the correct page title for a new record', () => {
const { result } = renderHooks();
const recordId = 'new-record-123';
const objectNameSingular = 'person';
act(() => {
result.current.openRecordInCommandMenu({
recordId,
objectNameSingular,
isNewRecord: true,
});
});
expect(mockNavigateCommandMenu).toHaveBeenCalledWith({
page: CommandMenuPages.ViewRecord,
pageTitle: 'New Person',
pageIcon: result.current.getIcon(personMockObjectMetadataItem.icon),
pageIconColor: 'currentColor',
pageId: 'mocked-uuid',
resetNavigationStack: false,
});
});
it('should open title cell in edit mode when isNewRecord is true', () => {
const { result } = renderHooks();
act(() => {
result.current.openRecordInCommandMenu({
recordId: 'new-record-123',
objectNameSingular: 'person',
isNewRecord: true,
});
});
expect(mockOpenNewRecordTitleCell).toHaveBeenCalledWith({
recordId: 'new-record-123',
fieldName: getLabelIdentifierFieldMetadataItem(
personMockObjectMetadataItem,
)?.name,
});
});
it('should not open title cell when isNewRecord is false', () => {
const { result } = renderHooks();
act(() => {
result.current.openRecordInCommandMenu({
recordId: 'record-123',
objectNameSingular: 'person',
});
});
expect(mockOpenNewRecordTitleCell).not.toHaveBeenCalled();
});
});
@@ -1,6 +0,0 @@
describe('useOpenUpdateMultipleRecordsPageInCommandMenu', () => {
it('should work', () => {
// const { result } = renderHook(() => useOpenUpdateMultipleRecordsPageInCommandMenu({ contextStoreInstanceId: 'test' }));
expect(true).toBe(true);
});
});
@@ -1,11 +1,11 @@
import { renderHook } from '@testing-library/react';
import { act } from 'react';
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
import { COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuPreviousComponentInstanceId';
import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId';
import { SIDE_PANEL_PREVIOUS_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelPreviousComponentInstanceId';
import { useSetGlobalCommandMenuContext } from '@/command-menu/hooks/useSetGlobalCommandMenuContext';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { hasUserSelectedCommandState } from '@/command-menu/states/hasUserSelectedCommandState';
import { sidePanelPageInfoState } from '@/side-panel/states/sidePanelPageInfoState';
import { hasUserSelectedSidePanelListItemState } from '@/side-panel/states/hasUserSelectedSidePanelListItemState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
@@ -37,7 +37,7 @@ jotaiStore.set(
const wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
componentInstanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID,
componentInstanceId: SIDE_PANEL_COMPONENT_INSTANCE_ID,
contextStoreCurrentObjectMetadataNameSingular:
personMockObjectMetadataItem.nameSingular,
contextStoreCurrentViewId: 'my-view-id',
@@ -62,32 +62,32 @@ describe('useSetGlobalCommandMenuContext', () => {
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
COMMAND_MENU_COMPONENT_INSTANCE_ID,
SIDE_PANEL_COMPONENT_INSTANCE_ID,
);
const contextStoreNumberOfSelectedRecords = useAtomComponentStateValue(
contextStoreNumberOfSelectedRecordsComponentState,
COMMAND_MENU_COMPONENT_INSTANCE_ID,
SIDE_PANEL_COMPONENT_INSTANCE_ID,
);
const contextStoreFilters = useAtomComponentStateValue(
contextStoreFiltersComponentState,
COMMAND_MENU_COMPONENT_INSTANCE_ID,
SIDE_PANEL_COMPONENT_INSTANCE_ID,
);
const contextStoreFilterGroups = useAtomComponentStateValue(
contextStoreFilterGroupsComponentState,
COMMAND_MENU_COMPONENT_INSTANCE_ID,
SIDE_PANEL_COMPONENT_INSTANCE_ID,
);
const contextStoreAnyFieldFilterValue = useAtomComponentStateValue(
contextStoreAnyFieldFilterValueComponentState,
COMMAND_MENU_COMPONENT_INSTANCE_ID,
SIDE_PANEL_COMPONENT_INSTANCE_ID,
);
const contextStoreCurrentViewType = useAtomComponentStateValue(
contextStoreCurrentViewTypeComponentState,
COMMAND_MENU_COMPONENT_INSTANCE_ID,
SIDE_PANEL_COMPONENT_INSTANCE_ID,
);
return {
@@ -115,16 +115,16 @@ describe('useSetGlobalCommandMenuContext', () => {
expect(result.current.contextStoreCurrentViewType).toBe(
ContextStoreViewType.Table,
);
const commandMenuPageInfo = jotaiStore.get(commandMenuPageInfoState.atom);
expect(commandMenuPageInfo).toEqual({
const sidePanelPageInfo = jotaiStore.get(sidePanelPageInfoState.atom);
expect(sidePanelPageInfo).toEqual({
title: undefined,
Icon: undefined,
instanceId: '',
});
const hasUserSelectedCommand = jotaiStore.get(
hasUserSelectedCommandState.atom,
const hasUserSelectedSidePanelListItem = jotaiStore.get(
hasUserSelectedSidePanelListItemState.atom,
);
expect(hasUserSelectedCommand).toBe(false);
expect(hasUserSelectedSidePanelListItem).toBe(false);
act(() => {
result.current.setGlobalCommandMenuContext();
@@ -140,18 +140,16 @@ describe('useSetGlobalCommandMenuContext', () => {
expect(result.current.contextStoreCurrentViewType).toBe(
ContextStoreViewType.Table,
);
const commandMenuPageInfoAfter = jotaiStore.get(
commandMenuPageInfoState.atom,
);
expect(commandMenuPageInfoAfter).toEqual({
const sidePanelPageInfoAfter = jotaiStore.get(sidePanelPageInfoState.atom);
expect(sidePanelPageInfoAfter).toEqual({
title: undefined,
Icon: undefined,
instanceId: '',
});
const hasUserSelectedCommandAfter = jotaiStore.get(
hasUserSelectedCommandState.atom,
const hasUserSelectedSidePanelListItemAfter = jotaiStore.get(
hasUserSelectedSidePanelListItemState.atom,
);
expect(hasUserSelectedCommandAfter).toBe(false);
expect(hasUserSelectedSidePanelListItemAfter).toBe(false);
});
it('should copy context store states to previous instance before resetting', () => {
@@ -163,13 +161,13 @@ describe('useSetGlobalCommandMenuContext', () => {
// eslint-disable-next-line twenty/matching-state-variable
const previousTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID,
SIDE_PANEL_PREVIOUS_COMPONENT_INSTANCE_ID,
);
// eslint-disable-next-line twenty/matching-state-variable
const previousNumberOfSelectedRecords = useAtomComponentStateValue(
contextStoreNumberOfSelectedRecordsComponentState,
COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID,
SIDE_PANEL_PREVIOUS_COMPONENT_INSTANCE_ID,
);
return {
@@ -1,140 +0,0 @@
import { useUpdateCommandMenuPageInfo } from '@/command-menu/hooks/useUpdateCommandMenuPageInfo';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { jotaiStore } from '@/ui/utilities/state/jotai/jotaiStore';
import { renderHook } from '@testing-library/react';
import { Provider as JotaiProvider } from 'jotai';
import { act } from 'react';
import { CommandMenuPages } from 'twenty-shared/types';
import { IconArrowDown, IconDotsVertical } from 'twenty-ui/display';
const mockedPageInfo = {
title: 'Initial Title',
Icon: IconDotsVertical,
instanceId: 'test-instance',
};
const mockedNavigationStack = [
{
page: CommandMenuPages.Root,
pageTitle: 'Initial Title',
pageIcon: IconDotsVertical,
pageId: 'test-page-id',
},
];
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
);
describe('useUpdateCommandMenuPageInfo', () => {
beforeEach(() => {
jotaiStore.set(commandMenuNavigationStackState.atom, mockedNavigationStack);
jotaiStore.set(commandMenuPageInfoState.atom, mockedPageInfo);
});
const renderHooks = () => {
const { result } = renderHook(
() => {
const { updateCommandMenuPageInfo } = useUpdateCommandMenuPageInfo();
return {
updateCommandMenuPageInfo,
};
},
{ wrapper: Wrapper },
);
return {
result,
};
};
it('should update command menu page info with new title and icon', () => {
const { result } = renderHooks();
act(() => {
result.current.updateCommandMenuPageInfo({
pageTitle: 'New Title',
pageIcon: IconArrowDown,
});
});
const commandMenuNavigationStack = jotaiStore.get(
commandMenuNavigationStackState.atom,
);
expect(commandMenuNavigationStack).toEqual([
{
page: CommandMenuPages.Root,
pageTitle: 'New Title',
pageIcon: IconArrowDown,
pageId: 'test-page-id',
},
]);
const commandMenuPageInfo = jotaiStore.get(commandMenuPageInfoState.atom);
expect(commandMenuPageInfo).toEqual({
title: 'New Title',
Icon: IconArrowDown,
instanceId: 'test-instance',
});
});
it('should update command menu page info with new title', () => {
const { result } = renderHooks();
act(() => {
result.current.updateCommandMenuPageInfo({
pageTitle: 'New Title',
});
});
const commandMenuNavigationStack = jotaiStore.get(
commandMenuNavigationStackState.atom,
);
expect(commandMenuNavigationStack).toEqual([
{
page: CommandMenuPages.Root,
pageTitle: 'New Title',
pageIcon: IconDotsVertical,
pageId: 'test-page-id',
},
]);
const commandMenuPageInfo = jotaiStore.get(commandMenuPageInfoState.atom);
expect(commandMenuPageInfo).toEqual({
title: 'New Title',
Icon: IconDotsVertical,
instanceId: 'test-instance',
});
});
it('should update command menu page info with new icon', () => {
const { result } = renderHooks();
act(() => {
result.current.updateCommandMenuPageInfo({
pageIcon: IconArrowDown,
});
});
const commandMenuNavigationStack = jotaiStore.get(
commandMenuNavigationStackState.atom,
);
expect(commandMenuNavigationStack).toEqual([
{
page: CommandMenuPages.Root,
pageTitle: 'Initial Title',
pageIcon: IconArrowDown,
pageId: 'test-page-id',
},
]);
const commandMenuPageInfo = jotaiStore.get(commandMenuPageInfoState.atom);
expect(commandMenuPageInfo).toEqual({
title: 'Initial Title',
Icon: IconArrowDown,
instanceId: 'test-instance',
});
});
});
@@ -1,10 +1,10 @@
import { renderHook } from '@testing-library/react';
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu';
import { viewableRecordNameSingularComponentState } from '@/command-menu/pages/record-page/states/viewableRecordNameSingularComponentState';
import { commandMenuWorkflowIdComponentState } from '@/command-menu/pages/workflow/states/commandMenuWorkflowIdComponentState';
import { commandMenuWorkflowVersionIdComponentState } from '@/command-menu/pages/workflow/states/commandMenuWorkflowVersionIdComponentState';
import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId';
import { useSidePanelWorkflowNavigation } from '@/side-panel/pages/workflow/hooks/useSidePanelWorkflowNavigation';
import { viewableRecordNameSingularComponentState } from '@/side-panel/pages/record-page/states/viewableRecordNameSingularComponentState';
import { sidePanelWorkflowIdComponentState } from '@/side-panel/pages/workflow/states/sidePanelWorkflowIdComponentState';
import { sidePanelWorkflowVersionIdComponentState } from '@/side-panel/pages/workflow/states/sidePanelWorkflowVersionIdComponentState';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
@@ -13,7 +13,7 @@ import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { t } from '@lingui/core/macro';
import { act } from 'react';
import { CommandMenuPages } from 'twenty-shared/types';
import { SidePanelPages } from 'twenty-shared/types';
import { IconBolt, IconSettingsAutomation, useIcons } from 'twenty-ui/display';
import { getJestMetadataAndApolloMocksAndActionMenuWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksAndActionMenuWrapper';
import { generatedMockObjectMetadataItems } from '~/testing/utils/generatedMockObjectMetadataItems';
@@ -23,9 +23,9 @@ jest.mock('uuid', () => ({
}));
const mockNavigateCommandMenu = jest.fn();
jest.mock('@/command-menu/hooks/useNavigateCommandMenu', () => ({
useNavigateCommandMenu: () => ({
navigateCommandMenu: mockNavigateCommandMenu,
jest.mock('@/side-panel/hooks/useNavigateSidePanel', () => ({
useNavigateSidePanel: () => ({
navigateSidePanel: mockNavigateCommandMenu,
}),
}));
@@ -41,7 +41,7 @@ jest.mock('@/object-metadata/hooks/useObjectMetadataItem', () => ({
const wrapper = getJestMetadataAndApolloMocksAndActionMenuWrapper({
apolloMocks: [],
componentInstanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID,
componentInstanceId: SIDE_PANEL_COMPONENT_INSTANCE_ID,
contextStoreCurrentObjectMetadataNameSingular:
workflowMockObjectMetadataItem.nameSingular,
contextStoreCurrentViewId: 'my-view-id',
@@ -57,12 +57,12 @@ const renderHooks = () => {
const { result } = renderHook(
() => {
const {
openWorkflowTriggerTypeInCommandMenu,
openWorkflowCreateStepInCommandMenu,
openWorkflowEditStepInCommandMenu,
openWorkflowEditStepTypeInCommandMenu,
openWorkflowViewStepInCommandMenu,
} = useWorkflowCommandMenu();
openWorkflowTriggerTypeInSidePanel,
openWorkflowCreateStepInSidePanel,
openWorkflowEditStepInSidePanel,
openWorkflowEditStepTypeInSidePanel,
openWorkflowViewStepInSidePanel,
} = useSidePanelWorkflowNavigation();
const viewableRecordNameSingular = useAtomComponentStateValue(
viewableRecordNameSingularComponentState,
@@ -85,24 +85,24 @@ const renderHooks = () => {
contextStoreCurrentViewTypeComponentState,
'mocked-uuid',
);
const commandMenuWorkflowId = useAtomComponentStateValue(
commandMenuWorkflowIdComponentState,
const sidePanelWorkflowId = useAtomComponentStateValue(
sidePanelWorkflowIdComponentState,
'mocked-uuid',
);
const commandMenuWorkflowVersionId = useAtomComponentStateValue(
commandMenuWorkflowVersionIdComponentState,
const sidePanelWorkflowVersionId = useAtomComponentStateValue(
sidePanelWorkflowVersionIdComponentState,
'mocked-uuid',
);
const { getIcon } = useIcons();
return {
openWorkflowTriggerTypeInCommandMenu,
openWorkflowCreateStepInCommandMenu,
openWorkflowEditStepInCommandMenu,
openWorkflowEditStepTypeInCommandMenu,
openWorkflowViewStepInCommandMenu,
commandMenuWorkflowId,
commandMenuWorkflowVersionId,
openWorkflowTriggerTypeInSidePanel,
openWorkflowCreateStepInSidePanel,
openWorkflowEditStepInSidePanel,
openWorkflowEditStepTypeInSidePanel,
openWorkflowViewStepInSidePanel,
sidePanelWorkflowId,
sidePanelWorkflowVersionId,
viewableRecordNameSingular,
contextStoreCurrentObjectMetadataItemId,
contextStoreTargetedRecordsRule,
@@ -118,7 +118,7 @@ const renderHooks = () => {
return { result };
};
describe('useWorkflowCommandMenu', () => {
describe('useSidePanelWorkflowNavigation', () => {
beforeEach(() => {
jest.clearAllMocks();
});
@@ -127,13 +127,13 @@ describe('useWorkflowCommandMenu', () => {
const { result } = renderHooks();
act(() => {
result.current.openWorkflowTriggerTypeInCommandMenu('test-workflow-id');
result.current.openWorkflowTriggerTypeInSidePanel('test-workflow-id');
});
expect(result.current.commandMenuWorkflowId).toBe('test-workflow-id');
expect(result.current.sidePanelWorkflowId).toBe('test-workflow-id');
expect(mockNavigateCommandMenu).toHaveBeenCalledWith({
page: CommandMenuPages.WorkflowTriggerSelectType,
page: SidePanelPages.WorkflowTriggerSelectType,
pageTitle: t`Trigger Type`,
pageIcon: IconBolt,
pageId: 'mocked-uuid',
@@ -144,13 +144,13 @@ describe('useWorkflowCommandMenu', () => {
const { result } = renderHooks();
act(() => {
result.current.openWorkflowCreateStepInCommandMenu('test-workflow-id');
result.current.openWorkflowCreateStepInSidePanel('test-workflow-id');
});
expect(result.current.commandMenuWorkflowId).toBe('test-workflow-id');
expect(result.current.sidePanelWorkflowId).toBe('test-workflow-id');
expect(mockNavigateCommandMenu).toHaveBeenCalledWith({
page: CommandMenuPages.WorkflowStepCreate,
page: SidePanelPages.WorkflowStepCreate,
pageTitle: t`Select Action`,
pageIcon: IconSettingsAutomation,
pageId: 'mocked-uuid',
@@ -161,13 +161,13 @@ describe('useWorkflowCommandMenu', () => {
const { result } = renderHooks();
act(() => {
result.current.openWorkflowEditStepTypeInCommandMenu('test-workflow-id');
result.current.openWorkflowEditStepTypeInSidePanel('test-workflow-id');
});
expect(result.current.commandMenuWorkflowId).toBe('test-workflow-id');
expect(result.current.sidePanelWorkflowId).toBe('test-workflow-id');
expect(mockNavigateCommandMenu).toHaveBeenCalledWith({
page: CommandMenuPages.WorkflowStepEditType,
page: SidePanelPages.WorkflowStepEditType,
pageTitle: t`Select action`,
pageIcon: IconSettingsAutomation,
pageId: 'mocked-uuid',
@@ -178,17 +178,17 @@ describe('useWorkflowCommandMenu', () => {
const { result } = renderHooks();
act(() => {
result.current.openWorkflowEditStepInCommandMenu(
result.current.openWorkflowEditStepInSidePanel(
'test-workflow-id',
'Edit Step',
IconSettingsAutomation,
);
});
expect(result.current.commandMenuWorkflowId).toBe('test-workflow-id');
expect(result.current.sidePanelWorkflowId).toBe('test-workflow-id');
expect(mockNavigateCommandMenu).toHaveBeenCalledWith({
page: CommandMenuPages.WorkflowStepEdit,
page: SidePanelPages.WorkflowStepEdit,
pageTitle: 'Edit Step',
pageIcon: IconSettingsAutomation,
pageId: 'mocked-uuid',
@@ -199,7 +199,7 @@ describe('useWorkflowCommandMenu', () => {
const { result } = renderHooks();
act(() => {
result.current.openWorkflowViewStepInCommandMenu({
result.current.openWorkflowViewStepInSidePanel({
workflowId: 'test-workflow-id',
workflowVersionId: 'test-workflow-version-id',
icon: IconSettingsAutomation,
@@ -207,13 +207,13 @@ describe('useWorkflowCommandMenu', () => {
});
});
expect(result.current.commandMenuWorkflowId).toBe('test-workflow-id');
expect(result.current.commandMenuWorkflowVersionId).toBe(
expect(result.current.sidePanelWorkflowId).toBe('test-workflow-id');
expect(result.current.sidePanelWorkflowVersionId).toBe(
'test-workflow-version-id',
);
expect(mockNavigateCommandMenu).toHaveBeenCalledWith({
page: CommandMenuPages.WorkflowStepView,
page: SidePanelPages.WorkflowStepView,
pageTitle: 'View Step',
pageIcon: IconSettingsAutomation,
pageId: 'mocked-uuid',
@@ -1,92 +0,0 @@
import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId';
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { isCommandMenuClosingState } from '@/command-menu/states/isCommandMenuClosingState';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { addToNavPayloadRegistryState } from '@/navigation-menu-item/states/addToNavPayloadRegistryState';
import { isNavigationMenuInEditModeState } from '@/navigation-menu-item/states/isNavigationMenuInEditModeState';
import { selectedNavigationMenuItemInEditModeState } from '@/navigation-menu-item/states/selectedNavigationMenuItemInEditModeState';
import { useCloseAnyOpenDropdown } from '@/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown';
import { emitSidePanelOpenEvent } from '@/ui/layout/right-drawer/utils/emitSidePanelOpenEvent';
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
import { t } from '@lingui/core/macro';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { CommandMenuPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconColumnInsertRight, IconDotsVertical } from 'twenty-ui/display';
export const useCommandMenu = () => {
const store = useStore();
const { navigateCommandMenu } = useNavigateCommandMenu();
const { closeAnyOpenDropdown } = useCloseAnyOpenDropdown();
const { removeFocusItemFromFocusStackById } =
useRemoveFocusItemFromFocusStackById();
const closeCommandMenu = useCallback(() => {
const isCommandMenuOpened = store.get(isCommandMenuOpenedState.atom);
if (isCommandMenuOpened) {
store.set(addToNavPayloadRegistryState.atom, new Map());
store.set(isCommandMenuOpenedState.atom, false);
store.set(isCommandMenuClosingState.atom, true);
closeAnyOpenDropdown();
removeFocusItemFromFocusStackById({
focusId: SIDE_PANEL_FOCUS_ID,
});
}
}, [closeAnyOpenDropdown, removeFocusItemFromFocusStackById, store]);
const openCommandMenu = useCallback(() => {
emitSidePanelOpenEvent();
closeAnyOpenDropdown();
const isNavigationMenuInEditMode = store.get(
isNavigationMenuInEditModeState.atom,
);
const selectedNavigationItemId = store.get(
selectedNavigationMenuItemInEditModeState.atom,
);
if (isNavigationMenuInEditMode && isDefined(selectedNavigationItemId)) {
navigateCommandMenu({
page: CommandMenuPages.NavigationMenuItemEdit,
pageTitle: t`Edit`,
pageIcon: IconDotsVertical,
resetNavigationStack: true,
});
} else if (isNavigationMenuInEditMode) {
navigateCommandMenu({
page: CommandMenuPages.NavigationMenuAddItem,
pageTitle: t`New sidebar item`,
pageIcon: IconColumnInsertRight,
resetNavigationStack: true,
});
} else {
navigateCommandMenu({
page: CommandMenuPages.Root,
pageTitle: t`Command Menu`,
pageIcon: IconDotsVertical,
resetNavigationStack: true,
});
}
}, [closeAnyOpenDropdown, navigateCommandMenu, store]);
const toggleCommandMenu = useCallback(() => {
const isCommandMenuOpened = store.get(isCommandMenuOpenedState.atom);
store.set(commandMenuSearchState.atom, '');
if (isCommandMenuOpened) {
closeCommandMenu();
} else {
openCommandMenu();
}
}, [closeCommandMenu, openCommandMenu, store]);
return {
openCommandMenu,
closeCommandMenu,
navigateCommandMenu,
toggleCommandMenu,
};
};
@@ -1,78 +0,0 @@
import { type ActionConfig } from '@/action-menu/actions/types/ActionConfig';
import { ActionScope } from '@/action-menu/actions/types/ActionScope';
import { ActionType } from '@/action-menu/actions/types/ActionType';
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { useContext } from 'react';
export const useCommandMenuActions = () => {
const { actions } = useContext(ActionMenuContext);
const navigateActions = actions?.filter(
(action) => action.type === ActionType.Navigation,
);
const actionRecordSelectionActions: ActionConfig[] = actions?.filter(
(action) =>
action.type === ActionType.Standard &&
action.scope === ActionScope.RecordSelection,
);
const actionObjectActions: ActionConfig[] = actions?.filter(
(action) =>
action.type === ActionType.Standard &&
action.scope === ActionScope.Object,
);
const actionGlobalActions: ActionConfig[] = actions?.filter(
(action) =>
action.type === ActionType.Standard &&
action.scope === ActionScope.Global,
);
const workflowRunRecordSelectionActions: ActionConfig[] = actions?.filter(
(action) =>
action.type === ActionType.WorkflowRun &&
action.scope === ActionScope.RecordSelection,
);
const workflowRunGlobalActions: ActionConfig[] = actions?.filter(
(action) =>
action.type === ActionType.WorkflowRun &&
action.scope === ActionScope.Global,
);
const frontComponentGlobalActions: ActionConfig[] = actions?.filter(
(action) =>
action.type === ActionType.FrontComponent &&
action.scope === ActionScope.Global,
);
const frontComponentRecordSelectionActions: ActionConfig[] = actions?.filter(
(action) =>
action.type === ActionType.FrontComponent &&
action.scope === ActionScope.RecordSelection,
);
const fallbackActions: ActionConfig[] = actions?.filter(
(action) => action.type === ActionType.Fallback,
);
const createRelatedRecordActions: ActionConfig[] = actions?.filter(
(action) =>
action.type === ActionType.Standard &&
action.scope === ActionScope.CreateRelatedRecord,
);
return {
navigateActions,
actionRecordSelectionActions,
actionGlobalActions,
actionObjectActions,
workflowRunRecordSelectionActions,
workflowRunGlobalActions,
frontComponentGlobalActions,
frontComponentRecordSelectionActions,
fallbackActions,
createRelatedRecordActions,
};
};
@@ -1,141 +0,0 @@
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
import { COMMAND_MENU_CONTEXT_CHIP_GROUPS_DROPDOWN_ID } from '@/command-menu/constants/CommandMenuContextChipGroupsDropdownId';
import { COMMAND_MENU_LIST_SELECTABLE_LIST_ID } from '@/command-menu/constants/CommandMenuListSelectableListId';
import { COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuPreviousComponentInstanceId';
import { useResetContextStoreStates } from '@/command-menu/hooks/useResetContextStoreStates';
import { commandMenuNavigationMorphItemsByPageState } from '@/command-menu/states/commandMenuNavigationMorphItemsByPageState';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { hasUserSelectedCommandState } from '@/command-menu/states/hasUserSelectedCommandState';
import { isCommandMenuClosingState } from '@/command-menu/states/isCommandMenuClosingState';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { pageLayoutDraggedAreaComponentState } from '@/page-layout/states/pageLayoutDraggedAreaComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
import { emitSidePanelCloseEvent } from '@/ui/layout/right-drawer/utils/emitSidePanelCloseEvent';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { getShowPageTabListComponentId } from '@/ui/layout/show-page/utils/getShowPageTabListComponentId';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { WORKFLOW_LOGIC_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/workflow/workflow-steps/workflow-actions/code-action/constants/WorkflowLogicFunctionTabListComponentId';
import { WorkflowLogicFunctionTabId } from '@/workflow/workflow-steps/workflow-actions/code-action/types/WorkflowLogicFunctionTabId';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { CommandMenuPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const useCommandMenuCloseAnimationCompleteCleanup = () => {
const store = useStore();
const { resetSelectedItem } = useSelectableList(
COMMAND_MENU_LIST_SELECTABLE_LIST_ID,
);
const { resetContextStoreStates } = useResetContextStoreStates();
const { closeDropdown } = useCloseDropdown();
const commandMenuCloseAnimationCompleteCleanup = useCallback(
(options?: { emitSidePanelCloseEvent?: boolean }) => {
closeDropdown(COMMAND_MENU_CONTEXT_CHIP_GROUPS_DROPDOWN_ID);
// Snapshot values before any mutations (Jotai store.get is live and
// reflects the latest state, so we capture before mutating).
const currentPage = store.get(commandMenuPageState.atom);
const targetedRecordsRule = store.get(
contextStoreTargetedRecordsRuleComponentState.atomFamily({
instanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID,
}),
);
const morphItemsByPage = store.get(
commandMenuNavigationMorphItemsByPageState.atom,
);
resetContextStoreStates(COMMAND_MENU_COMPONENT_INSTANCE_ID);
resetContextStoreStates(COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID);
const isPageLayoutEditingPage =
currentPage === CommandMenuPages.PageLayoutWidgetTypeSelect ||
currentPage === CommandMenuPages.PageLayoutGraphTypeSelect ||
currentPage === CommandMenuPages.PageLayoutIframeSettings ||
currentPage === CommandMenuPages.PageLayoutTabSettings;
if (isPageLayoutEditingPage) {
if (
targetedRecordsRule.mode === 'selection' &&
targetedRecordsRule.selectedRecordIds.length === 1
) {
const recordId = targetedRecordsRule.selectedRecordIds[0];
const record = store.get(recordStoreFamilyState.atomFamily(recordId));
if (isDefined(record) && isDefined(record.pageLayoutId)) {
store.set(
pageLayoutEditingWidgetIdComponentState.atomFamily({
instanceId: record.pageLayoutId,
}),
null,
);
store.set(
pageLayoutTabSettingsOpenTabIdComponentState.atomFamily({
instanceId: record.pageLayoutId,
}),
null,
);
store.set(
pageLayoutDraggedAreaComponentState.atomFamily({
instanceId: record.pageLayoutId,
}),
null,
);
}
}
}
store.set(viewableRecordIdState.atom, null);
store.set(commandMenuPageState.atom, CommandMenuPages.Root);
store.set(commandMenuPageInfoState.atom, {
title: undefined,
Icon: undefined,
instanceId: '',
});
store.set(isCommandMenuOpenedState.atom, false);
store.set(commandMenuSearchState.atom, '');
store.set(commandMenuNavigationMorphItemsByPageState.atom, new Map());
store.set(commandMenuNavigationStackState.atom, []);
resetSelectedItem();
store.set(hasUserSelectedCommandState.atom, false);
if (options?.emitSidePanelCloseEvent !== false) {
emitSidePanelCloseEvent();
}
store.set(isCommandMenuClosingState.atom, false);
store.set(
activeTabIdComponentState.atomFamily({
instanceId: WORKFLOW_LOGIC_FUNCTION_TAB_LIST_COMPONENT_ID,
}),
WorkflowLogicFunctionTabId.CODE,
);
for (const [pageId, morphItems] of morphItemsByPage) {
store.set(
activeTabIdComponentState.atomFamily({
instanceId: getShowPageTabListComponentId({
pageId,
targetObjectId: morphItems[0].recordId,
}),
}),
null,
);
}
},
[closeDropdown, resetContextStoreStates, resetSelectedItem, store],
);
return {
commandMenuCloseAnimationCompleteCleanup,
};
};
@@ -1,150 +0,0 @@
import { CommandMenuContextChipIconWrapper } from '@/command-menu/components/CommandMenuContextChipIconWrapper';
import { CommandMenuContextRecordChipAvatars } from '@/command-menu/components/CommandMenuContextRecordChipAvatars';
import { useCommandMenuHistory } from '@/command-menu/hooks/useCommandMenuHistory';
import { commandMenuNavigationMorphItemsByPageState } from '@/command-menu/states/commandMenuNavigationMorphItemsByPageState';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { allowRequestsToTwentyIconsState } from '@/client-config/states/allowRequestsToTwentyIcons';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { recordStoreIdentifiersFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreIdentifiersSelector';
import { recordStoreRecordsSelector } from '@/object-record/record-store/states/selectors/recordStoreRecordsSelector';
import { useAtomFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomFamilySelectorValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useContext, useMemo } from 'react';
import { CommandMenuPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
export const useCommandMenuContextChips = () => {
const { theme } = useContext(ThemeContext);
const iconSizeSm = theme.icon.size.sm;
const commandMenuNavigationStack = useAtomStateValue(
commandMenuNavigationStackState,
);
const allowRequestsToTwentyIcons = useAtomStateValue(
allowRequestsToTwentyIconsState,
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsState);
const { navigateCommandMenuHistory } = useCommandMenuHistory();
const commandMenuNavigationMorphItemsByPage = useAtomStateValue(
commandMenuNavigationMorphItemsByPageState,
);
const allRecordIds = Array.from(
commandMenuNavigationMorphItemsByPage.entries(),
).flatMap(([, morphItems]) =>
morphItems.map((morphItem) => morphItem.recordId),
);
const recordIdentifiers = useAtomFamilySelectorValue(
recordStoreIdentifiersFamilySelector,
{
recordIds: allRecordIds,
allowRequestsToTwentyIcons,
},
);
const records = useAtomFamilySelectorValue(recordStoreRecordsSelector, {
recordIds: allRecordIds,
});
const contextChips = useMemo(() => {
const filteredCommandMenuNavigationStack =
commandMenuNavigationStack.filter(
(page) => page.page !== CommandMenuPages.Root,
);
return filteredCommandMenuNavigationStack
.map((page, index) => {
const isLastChip =
index === filteredCommandMenuNavigationStack.length - 1;
const isRecordPage = page.page === CommandMenuPages.ViewRecord;
if (isRecordPage && !isLastChip) {
const commandMenuNavigationMorphItem =
commandMenuNavigationMorphItemsByPage.get(page.pageId)?.[0];
if (!isDefined(commandMenuNavigationMorphItem?.recordId)) {
return null;
}
const objectMetadataItem = objectMetadataItems.find(
(item) =>
item.id === commandMenuNavigationMorphItem.objectMetadataId,
);
const recordIdentifier = recordIdentifiers.find(
(recordIdentifier) =>
recordIdentifier.id === commandMenuNavigationMorphItem.recordId,
);
const record = records.find(
(record) => record.id === commandMenuNavigationMorphItem.recordId,
);
if (
!isDefined(objectMetadataItem) ||
!isDefined(recordIdentifier) ||
!isDefined(record)
) {
return null;
}
return {
page,
Icons: [
<CommandMenuContextRecordChipAvatars
objectMetadataItem={objectMetadataItem}
record={record}
/>,
],
text: recordIdentifier.name,
onClick: () => {
navigateCommandMenuHistory(index);
},
};
}
return {
page,
Icons: isLastChip
? [<page.pageIcon size={iconSizeSm} />]
: [
<CommandMenuContextChipIconWrapper>
<page.pageIcon
size={iconSizeSm}
color={
isDefined(page.pageIconColor) &&
page.pageIconColor !== 'currentColor'
? page.pageIconColor
: themeCssVariables.font.color.tertiary
}
/>
</CommandMenuContextChipIconWrapper>,
],
text: page.pageTitle,
onClick: isLastChip
? undefined
: () => {
navigateCommandMenuHistory(index);
},
};
})
.filter(isDefined);
}, [
commandMenuNavigationMorphItemsByPage,
commandMenuNavigationStack,
iconSizeSm,
navigateCommandMenuHistory,
objectMetadataItems,
recordIdentifiers,
records,
]);
return {
contextChips,
};
};
@@ -1,134 +0,0 @@
import { useCallback } from 'react';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { commandMenuNavigationMorphItemsByPageState } from '@/command-menu/states/commandMenuNavigationMorphItemsByPageState';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { hasUserSelectedCommandState } from '@/command-menu/states/hasUserSelectedCommandState';
import { getShowPageTabListComponentId } from '@/ui/layout/show-page/utils/getShowPageTabListComponentId';
import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
import { isNonEmptyArray } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import { useStore } from 'jotai';
export const useCommandMenuHistory = () => {
const store = useStore();
const { closeCommandMenu } = useCommandMenu();
const goBackFromCommandMenu = useCallback(() => {
const currentNavigationStack = store.get(
commandMenuNavigationStackState.atom,
);
const newNavigationStack = currentNavigationStack.slice(0, -1);
const lastNavigationStackItem = newNavigationStack.at(-1);
if (!isDefined(lastNavigationStackItem)) {
closeCommandMenu();
return;
}
store.set(commandMenuPageState.atom, lastNavigationStackItem.page);
store.set(commandMenuPageInfoState.atom, {
title: lastNavigationStackItem.pageTitle,
Icon: lastNavigationStackItem.pageIcon,
instanceId: lastNavigationStackItem.pageId,
});
store.set(commandMenuNavigationStackState.atom, newNavigationStack);
const currentMorphItems = store.get(
commandMenuNavigationMorphItemsByPageState.atom,
);
if (currentNavigationStack.length > 0) {
const removedItem = currentNavigationStack.at(-1);
if (isDefined(removedItem)) {
const newMorphItems = new Map(currentMorphItems);
newMorphItems.delete(removedItem.pageId);
store.set(
commandMenuNavigationMorphItemsByPageState.atom,
newMorphItems,
);
const morphItems = currentMorphItems.get(removedItem.pageId);
if (isNonEmptyArray(morphItems)) {
store.set(
activeTabIdComponentState.atomFamily({
instanceId: getShowPageTabListComponentId({
pageId: removedItem.pageId,
targetObjectId: morphItems[0].recordId,
}),
}),
null,
);
}
}
}
store.set(hasUserSelectedCommandState.atom, false);
}, [closeCommandMenu, store]);
const navigateCommandMenuHistory = useCallback(
(pageIndex: number) => {
const currentNavigationStack = store.get(
commandMenuNavigationStackState.atom,
);
const newNavigationStack = currentNavigationStack.slice(0, pageIndex + 1);
store.set(commandMenuNavigationStackState.atom, newNavigationStack);
const newNavigationStackItem = newNavigationStack.at(-1);
if (!isDefined(newNavigationStackItem)) {
throw new Error(
`No command menu navigation stack item found for index ${pageIndex}`,
);
}
store.set(commandMenuPageState.atom, newNavigationStackItem.page);
store.set(commandMenuPageInfoState.atom, {
title: newNavigationStackItem.pageTitle,
Icon: newNavigationStackItem.pageIcon,
instanceId: newNavigationStackItem.pageId,
});
const currentMorphItems = store.get(
commandMenuNavigationMorphItemsByPageState.atom,
);
for (const [pageId, morphItems] of currentMorphItems.entries()) {
if (!newNavigationStack.some((item) => item.pageId === pageId)) {
store.set(
activeTabIdComponentState.atomFamily({
instanceId: getShowPageTabListComponentId({
pageId,
targetObjectId: morphItems[0].recordId,
}),
}),
null,
);
}
}
const newMorphItems = new Map(
Array.from(currentMorphItems.entries()).filter(([pageId]) =>
newNavigationStack.some((item) => item.pageId === pageId),
),
);
store.set(commandMenuNavigationMorphItemsByPageState.atom, newMorphItems);
store.set(hasUserSelectedCommandState.atom, false);
},
[store],
);
return {
goBackFromCommandMenu,
navigateCommandMenuHistory,
};
};
@@ -1,12 +1,12 @@
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useCommandMenuHistory } from '@/command-menu/hooks/useCommandMenuHistory';
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
import { useOpenRecordsSearchPageInCommandMenu } from '@/command-menu/hooks/useOpenRecordsSearchPageInCommandMenu';
import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId';
import { SIDE_PANEL_FOCUS_ID } from '@/side-panel/constants/SidePanelFocusId';
import { useSidePanelMenu } from '@/side-panel/hooks/useSidePanelMenu';
import { useSidePanelHistory } from '@/side-panel/hooks/useSidePanelHistory';
import { useOpenAskAIPageInSidePanel } from '@/side-panel/hooks/useOpenAskAIPageInSidePanel';
import { useOpenRecordsSearchPageInSidePanel } from '@/side-panel/hooks/useOpenRecordsSearchPageInSidePanel';
import { useSetGlobalCommandMenuContext } from '@/command-menu/hooks/useSetGlobalCommandMenuContext';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { sidePanelPageState } from '@/side-panel/states/sidePanelPageState';
import { sidePanelSearchState } from '@/side-panel/states/sidePanelSearchState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { useKeyboardShortcutMenu } from '@/keyboard-shortcut-menu/hooks/useKeyboardShortcutMenu';
import { useGlobalHotkeys } from '@/ui/utilities/hotkey/hooks/useGlobalHotkeys';
@@ -16,41 +16,41 @@ import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { isNonEmptyString } from '@sniptt/guards';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { Key } from 'ts-key-enum';
import { CommandMenuPages } from 'twenty-shared/types';
import { SidePanelPages } from 'twenty-shared/types';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const useCommandMenuHotKeys = () => {
const { toggleCommandMenu } = useCommandMenu();
const { toggleSidePanelMenu } = useSidePanelMenu();
const { openRecordsSearchPage } = useOpenRecordsSearchPageInCommandMenu();
const { openRecordsSearchPage } = useOpenRecordsSearchPageInSidePanel();
const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
const { openAskAIPage } = useOpenAskAIPageInSidePanel();
const { goBackFromCommandMenu } = useCommandMenuHistory();
const { goBackFromSidePanel } = useSidePanelHistory();
const { setGlobalCommandMenuContext } = useSetGlobalCommandMenuContext();
const commandMenuSearch = useAtomStateValue(commandMenuSearchState);
const sidePanelSearch = useAtomStateValue(sidePanelSearchState);
const { closeKeyboardShortcutMenu } = useKeyboardShortcutMenu();
const commandMenuPage = useAtomStateValue(commandMenuPageState);
const sidePanelPage = useAtomStateValue(sidePanelPageState);
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const contextStoreTargetedRecordsRule = useAtomComponentStateValue(
contextStoreTargetedRecordsRuleComponentState,
COMMAND_MENU_COMPONENT_INSTANCE_ID,
SIDE_PANEL_COMPONENT_INSTANCE_ID,
);
useGlobalHotkeys({
keys: ['ctrl+k', 'meta+k'],
callback: () => {
closeKeyboardShortcutMenu();
toggleCommandMenu();
toggleSidePanelMenu();
},
containsModifier: true,
dependencies: [closeKeyboardShortcutMenu, toggleCommandMenu],
dependencies: [closeKeyboardShortcutMenu, toggleSidePanelMenu],
});
useGlobalHotkeys({
@@ -82,10 +82,10 @@ export const useCommandMenuHotKeys = () => {
useHotkeysOnFocusedElement({
keys: [Key.Escape],
callback: () => {
goBackFromCommandMenu();
goBackFromSidePanel();
},
focusId: SIDE_PANEL_FOCUS_ID,
dependencies: [goBackFromCommandMenu],
dependencies: [goBackFromSidePanel],
options: {
enableOnFormTags: false,
},
@@ -94,12 +94,12 @@ export const useCommandMenuHotKeys = () => {
useHotkeysOnFocusedElement({
keys: [Key.Backspace, Key.Delete],
callback: () => {
if (isNonEmptyString(commandMenuSearch)) {
if (isNonEmptyString(sidePanelSearch)) {
return;
}
if (
commandMenuPage === CommandMenuPages.Root &&
sidePanelPage === SidePanelPages.Root &&
!(
contextStoreTargetedRecordsRule.mode === 'selection' &&
contextStoreTargetedRecordsRule.selectedRecordIds.length === 0
@@ -107,16 +107,16 @@ export const useCommandMenuHotKeys = () => {
) {
setGlobalCommandMenuContext();
}
if (commandMenuPage !== CommandMenuPages.Root) {
goBackFromCommandMenu();
if (sidePanelPage !== SidePanelPages.Root) {
goBackFromSidePanel();
}
},
focusId: SIDE_PANEL_FOCUS_ID,
dependencies: [
commandMenuPage,
commandMenuSearch,
sidePanelPage,
sidePanelSearch,
contextStoreTargetedRecordsRule,
goBackFromCommandMenu,
goBackFromSidePanel,
setGlobalCommandMenuContext,
],
options: {
@@ -1,139 +0,0 @@
import { Action } from '@/action-menu/actions/components/Action';
import { ActionLink } from '@/action-menu/actions/components/ActionLink';
import { ActionScope } from '@/action-menu/actions/types/ActionScope';
import { ActionType } from '@/action-menu/actions/types/ActionType';
import { MAX_SEARCH_RESULTS } from '@/command-menu/constants/MaxSearchResults';
import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordInCommandMenu';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular, AppPath } from 'twenty-shared/types';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId';
import { t } from '@lingui/core/macro';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMemo } from 'react';
import { Avatar } from 'twenty-ui/display';
import { useDebounce } from 'use-debounce';
import { useSearchQuery } from '~/generated/graphql';
export const useCommandMenuSearchRecords = () => {
const commandMenuSearch = useAtomStateValue(commandMenuSearchState);
const coreClient = useApolloCoreClient();
const [deferredCommandMenuSearch] = useDebounce(commandMenuSearch, 300);
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const { objectMetadataItems } = useObjectMetadataItems();
const nonReadableObjectMetadataItemsNameSingular = useMemo(() => {
return Object.values(objectMetadataItems)
.filter((objectMetadataItem) => {
const objectPermission = getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: objectMetadataItem.id,
});
return !objectPermission?.canReadObjectRecords;
})
.map((objectMetadataItem) => objectMetadataItem.nameSingular);
}, [objectMetadataItems, objectPermissionsByObjectMetadataId]);
const { data: searchData, loading } = useSearchQuery({
client: coreClient,
variables: {
searchInput: deferredCommandMenuSearch ?? '',
limit: MAX_SEARCH_RESULTS,
excludedObjectNameSingulars: [
'workspaceMember',
...nonReadableObjectMetadataItemsNameSingular,
],
},
});
const { openRecordInCommandMenu } = useOpenRecordInCommandMenu();
const actionItems = useMemo(() => {
return (searchData?.search.edges.map((edge) => edge.node) ?? []).map(
(searchRecord, index) => {
const baseAction = {
type: ActionType.Navigation,
scope: ActionScope.Global,
key: searchRecord.recordId,
label: searchRecord.label,
position: index,
Icon: () => (
<Avatar
type={
searchRecord.objectNameSingular ===
CoreObjectNameSingular.Company
? 'squared'
: 'rounded'
}
avatarUrl={searchRecord.imageUrl}
placeholderColorSeed={searchRecord.recordId}
placeholder={searchRecord.label}
/>
),
shouldBeRegistered: () => true,
description:
objectMetadataItems.find(
(item) => item.nameSingular === searchRecord.objectNameSingular,
)?.labelSingular ?? searchRecord.objectNameSingular,
};
if (
[CoreObjectNameSingular.Task, CoreObjectNameSingular.Note].includes(
searchRecord.objectNameSingular as CoreObjectNameSingular,
)
) {
return {
...baseAction,
component: (
<Action
onClick={() => {
searchRecord.objectNameSingular === 'task'
? openRecordInCommandMenu({
recordId: searchRecord.recordId,
objectNameSingular: CoreObjectNameSingular.Task,
})
: openRecordInCommandMenu({
recordId: searchRecord.recordId,
objectNameSingular: CoreObjectNameSingular.Note,
});
}}
closeSidePanelOnCommandMenuListActionExecution={false}
/>
),
};
}
return {
...baseAction,
component: (
<ActionLink
to={AppPath.RecordShowPage}
params={{
objectNameSingular: searchRecord.objectNameSingular,
objectRecordId: searchRecord.recordId,
}}
/>
),
};
},
);
}, [searchData, openRecordInCommandMenu, objectMetadataItems]);
return {
loading,
noResults: !actionItems?.length,
commandGroups: [
{
heading: t`Results`,
items: actionItems,
},
],
hasMore: false,
pageSize: 0,
onLoadMore: () => {},
};
};
@@ -1,41 +0,0 @@
import { commandMenuNavigationMorphItemsByPageState } from '@/command-menu/states/commandMenuNavigationMorphItemsByPageState';
import { useCallback } from 'react';
import { useStore } from 'jotai';
type UpdateNavigationMorphItemsByPageParams = {
pageId: string;
objectMetadataId: string;
objectRecordIds: string[];
};
export const useCommandMenuUpdateNavigationMorphItemsByPage = () => {
const store = useStore();
const updateCommandMenuNavigationMorphItemsByPage = useCallback(
async ({
pageId,
objectMetadataId,
objectRecordIds,
}: UpdateNavigationMorphItemsByPageParams) => {
const currentMorphItems = store.get(
commandMenuNavigationMorphItemsByPageState.atom,
);
const newMorphItems = objectRecordIds.map((recordId) => ({
objectMetadataId,
recordId,
}));
const newMorphItemsMap = new Map(currentMorphItems);
newMorphItemsMap.set(pageId, newMorphItems);
store.set(
commandMenuNavigationMorphItemsByPageState.atom,
newMorphItemsMap,
);
},
[store],
);
return {
updateCommandMenuNavigationMorphItemsByPage,
};
};
@@ -1,44 +0,0 @@
import { type ActionConfig } from '@/action-menu/actions/types/ActionConfig';
import { getActionLabel } from '@/action-menu/utils/getActionLabel';
import { isNonEmptyString } from '@sniptt/guards';
import { useCallback } from 'react';
import { normalizeSearchText } from '~/utils/normalizeSearchText';
const checkInShortcuts = (action: ActionConfig, search: string) => {
const concatenatedString = action.hotKeys?.join('') ?? '';
const searchNormalized = normalizeSearchText(search.trim());
return normalizeSearchText(concatenatedString).includes(searchNormalized);
};
const checkInLabels = (action: ActionConfig, search: string) => {
const actionLabel = getActionLabel(action.label);
if (isNonEmptyString(actionLabel)) {
const searchNormalized = normalizeSearchText(search);
return normalizeSearchText(actionLabel).includes(searchNormalized);
}
return false;
};
type UseFilterActionsWithCommandMenuSearchProps = {
commandMenuSearch: string;
};
export const useFilterActionsWithCommandMenuSearch = ({
commandMenuSearch,
}: UseFilterActionsWithCommandMenuSearchProps) => {
const filterActionsWithCommandMenuSearch = useCallback(
(actions: ActionConfig[]) => {
return actions.filter((action) =>
commandMenuSearch.length > 0
? checkInShortcuts(action, commandMenuSearch) ||
checkInLabels(action, commandMenuSearch)
: true,
);
},
[commandMenuSearch],
);
return {
filterActionsWithCommandMenuSearch,
};
};
@@ -1,43 +0,0 @@
import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
type UseFilteredPickerItemsParams<T> = {
items: T[];
searchQuery: string;
getSearchableValues: (item: T) => string[];
appendSelectableIds?: string[];
};
type UseFilteredPickerItemsResult<T> = {
filteredItems: T[];
selectableItemIds: string[];
isEmpty: boolean;
hasSearchQuery: boolean;
};
export const useFilteredPickerItems = <T extends { id: string }>({
items,
searchQuery,
getSearchableValues,
appendSelectableIds = [],
}: UseFilteredPickerItemsParams<T>): UseFilteredPickerItemsResult<T> => {
const filteredItems = filterBySearchQuery({
items,
searchQuery,
getSearchableValues,
});
const selectableItemIds =
filteredItems.length > 0
? [...filteredItems.map((item) => item.id), ...appendSelectableIds]
: appendSelectableIds;
const isEmpty = filteredItems.length === 0;
const hasSearchQuery = searchQuery.trim().length > 0;
return {
filteredItems,
selectableItemIds,
isEmpty,
hasSearchQuery,
};
};
@@ -1,80 +0,0 @@
import { useCommandMenuActions } from '@/command-menu/hooks/useCommandMenuActions';
import { useFilterActionsWithCommandMenuSearch } from '@/command-menu/hooks/useFilterActionsWithCommandMenuSearch';
export const useMatchingCommandMenuActions = ({
commandMenuSearch,
}: {
commandMenuSearch: string;
}) => {
const { filterActionsWithCommandMenuSearch } =
useFilterActionsWithCommandMenuSearch({
commandMenuSearch,
});
const {
navigateActions,
actionRecordSelectionActions,
actionObjectActions,
actionGlobalActions,
workflowRunRecordSelectionActions,
workflowRunGlobalActions,
frontComponentGlobalActions,
frontComponentRecordSelectionActions,
fallbackActions,
createRelatedRecordActions,
} = useCommandMenuActions();
const matchingNavigateActions =
filterActionsWithCommandMenuSearch(navigateActions);
const matchingStandardActionRecordSelectionActions =
filterActionsWithCommandMenuSearch(actionRecordSelectionActions);
const matchingStandardActionObjectActions =
filterActionsWithCommandMenuSearch(actionObjectActions);
const matchingStandardActionGlobalActions =
filterActionsWithCommandMenuSearch(actionGlobalActions);
const matchingWorkflowRunRecordSelectionActions =
filterActionsWithCommandMenuSearch(workflowRunRecordSelectionActions);
const matchingWorkflowRunGlobalActions = filterActionsWithCommandMenuSearch(
workflowRunGlobalActions,
);
const matchingFrontComponentGlobalActions =
filterActionsWithCommandMenuSearch(frontComponentGlobalActions);
const matchingFrontComponentRecordSelectionActions =
filterActionsWithCommandMenuSearch(frontComponentRecordSelectionActions);
const matchingCreateRelatedRecordActions = filterActionsWithCommandMenuSearch(
createRelatedRecordActions,
);
const noResults =
!matchingStandardActionRecordSelectionActions.length &&
!matchingWorkflowRunRecordSelectionActions.length &&
!matchingFrontComponentRecordSelectionActions.length &&
!matchingStandardActionGlobalActions.length &&
!matchingWorkflowRunGlobalActions.length &&
!matchingFrontComponentGlobalActions.length &&
!matchingStandardActionObjectActions.length &&
!matchingNavigateActions.length &&
!matchingCreateRelatedRecordActions.length;
return {
noResults,
matchingStandardActionRecordSelectionActions,
matchingStandardActionObjectActions,
matchingWorkflowRunRecordSelectionActions,
matchingFrontComponentRecordSelectionActions,
matchingStandardActionGlobalActions,
matchingWorkflowRunGlobalActions,
matchingFrontComponentGlobalActions,
matchingNavigateActions,
matchingCreateRelatedRecordActions,
fallbackActions: noResults ? fallbackActions : [],
};
};
@@ -1,148 +0,0 @@
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId';
import { useCommandMenuCloseAnimationCompleteCleanup } from '@/command-menu/hooks/useCommandMenuCloseAnimationCompleteCleanup';
import { useCopyContextStoreStates } from '@/command-menu/hooks/useCopyContextStoreAndActionMenuStates';
import { commandMenuNavigationMorphItemsByPageState } from '@/command-menu/states/commandMenuNavigationMorphItemsByPageState';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { commandMenuShouldFocusTitleInputComponentState } from '@/command-menu/states/commandMenuShouldFocusTitleInputComponentState';
import { hasUserSelectedCommandState } from '@/command-menu/states/hasUserSelectedCommandState';
import { isCommandMenuClosingState } from '@/command-menu/states/isCommandMenuClosingState';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { type CommandMenuPages } from 'twenty-shared/types';
import { type IconComponent } from 'twenty-ui/display';
import { v4 } from 'uuid';
export type CommandMenuNavigationStackItem = {
page: CommandMenuPages;
pageTitle: string;
pageIcon: IconComponent;
pageIconColor?: string;
pageId?: string;
};
export const useNavigateCommandMenu = () => {
const store = useStore();
const { copyContextStoreStates } = useCopyContextStoreStates();
const { commandMenuCloseAnimationCompleteCleanup } =
useCommandMenuCloseAnimationCompleteCleanup();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const openCommandMenu = useCallback(() => {
const isCommandMenuOpened = store.get(isCommandMenuOpenedState.atom);
const isCommandMenuClosing = store.get(isCommandMenuClosingState.atom);
if (isCommandMenuClosing) {
commandMenuCloseAnimationCompleteCleanup({
emitSidePanelCloseEvent: false,
});
}
if (isCommandMenuOpened) {
return;
}
pushFocusItemToFocusStack({
focusId: SIDE_PANEL_FOCUS_ID,
component: {
type: FocusComponentType.SIDE_PANEL,
instanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID,
},
globalHotkeysConfig: {
enableGlobalHotkeysConflictingWithKeyboard: false,
},
});
copyContextStoreStates({
instanceIdToCopyFrom: MAIN_CONTEXT_STORE_INSTANCE_ID,
instanceIdToCopyTo: COMMAND_MENU_COMPONENT_INSTANCE_ID,
});
store.set(isCommandMenuOpenedState.atom, true);
store.set(hasUserSelectedCommandState.atom, false);
}, [
copyContextStoreStates,
commandMenuCloseAnimationCompleteCleanup,
pushFocusItemToFocusStack,
store,
]);
const navigateCommandMenu = useCallback(
({
page,
pageTitle,
pageIcon,
pageIconColor,
pageId,
focusTitleInput = false,
resetNavigationStack = false,
}: CommandMenuNavigationStackItem & {
resetNavigationStack?: boolean;
focusTitleInput?: boolean;
}) => {
const computedPageId = pageId || v4();
openCommandMenu();
store.set(commandMenuPageState.atom, page);
store.set(commandMenuPageInfoState.atom, {
title: pageTitle,
Icon: pageIcon,
instanceId: computedPageId,
});
if (focusTitleInput) {
store.set(
commandMenuShouldFocusTitleInputComponentState.atomFamily({
instanceId: computedPageId,
}),
true,
);
}
const isCommandMenuClosing = store.get(isCommandMenuClosingState.atom);
const currentNavigationStack = isCommandMenuClosing
? []
: store.get(commandMenuNavigationStackState.atom);
if (resetNavigationStack) {
store.set(commandMenuNavigationStackState.atom, [
{
page,
pageTitle,
pageIcon,
pageIconColor,
pageId: computedPageId,
},
]);
store.set(commandMenuNavigationMorphItemsByPageState.atom, new Map());
} else {
store.set(commandMenuNavigationStackState.atom, [
...currentNavigationStack,
{
page,
pageTitle,
pageIcon,
pageIconColor,
pageId: computedPageId,
},
]);
}
},
[openCommandMenu, store],
);
return {
navigateCommandMenu,
};
};
@@ -1,39 +0,0 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { useCallback } from 'react';
import { CommandMenuPages } from 'twenty-shared/types';
import { IconSparkles } from 'twenty-ui/display';
import { v4 } from 'uuid';
export const useOpenAskAIPageInCommandMenu = () => {
const { navigateCommandMenu } = useCommandMenu();
const isCommandMenuOpened = useAtomStateValue(isCommandMenuOpenedState);
const openAskAIPage = useCallback(
({
resetNavigationStack,
}: {
resetNavigationStack?: boolean;
} = {}) => {
const shouldReset =
resetNavigationStack !== undefined
? resetNavigationStack
: isCommandMenuOpened;
navigateCommandMenu({
page: CommandMenuPages.AskAI,
pageTitle: t`Ask AI`,
pageIcon: IconSparkles,
pageId: v4(),
resetNavigationStack: shouldReset,
});
},
[navigateCommandMenu, isCommandMenuOpened],
);
return {
openAskAIPage,
};
};
@@ -1,63 +0,0 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { viewableRecordIdComponentState } from '@/command-menu/pages/record-page/states/viewableRecordIdComponentState';
import { t } from '@lingui/core/macro';
import { useCallback } from 'react';
import { CommandMenuPages } from 'twenty-shared/types';
import { IconCalendarEvent } from 'twenty-ui/display';
import { v4 } from 'uuid';
import { useStore } from 'jotai';
export const useOpenCalendarEventInCommandMenu = () => {
const store = useStore();
const { navigateCommandMenu } = useNavigateCommandMenu();
const openCalendarEventInCommandMenu = useCallback(
(calendarEventId: string) => {
const pageComponentInstanceId = v4();
store.set(
viewableRecordIdComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
calendarEventId,
);
// TODO: Uncomment this once we need to calendar event title in the navigation
// const objectMetadataItem = snapshot
// .getLoadable(objectMetadataItemsState)
// .getValue()
// .find(
// ({ nameSingular }) =>
// nameSingular === CoreObjectNameSingular.CalendarEvent,
// );
// set(
// commandMenuNavigationMorphItemsState,
// new Map([
// ...snapshot
// .getLoadable(commandMenuNavigationMorphItemsState)
// .getValue(),
// [
// pageComponentInstanceId,
// {
// objectMetadataId: objectMetadataItem?.id,
// recordId: calendarEventId,
// },
// ],
// ]),
// );
navigateCommandMenu({
page: CommandMenuPages.ViewCalendarEvent,
pageTitle: t`Calendar Event`,
pageIcon: IconCalendarEvent,
pageId: pageComponentInstanceId,
});
},
[navigateCommandMenu, store],
);
return {
openCalendarEventInCommandMenu,
};
};
@@ -1,63 +0,0 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { viewableRecordIdComponentState } from '@/command-menu/pages/record-page/states/viewableRecordIdComponentState';
import { t } from '@lingui/core/macro';
import { useCallback } from 'react';
import { CommandMenuPages } from 'twenty-shared/types';
import { IconMail } from 'twenty-ui/display';
import { v4 } from 'uuid';
import { useStore } from 'jotai';
export const useOpenEmailThreadInCommandMenu = () => {
const store = useStore();
const { navigateCommandMenu } = useNavigateCommandMenu();
const openEmailThreadInCommandMenu = useCallback(
(emailThreadId: string) => {
const pageComponentInstanceId = v4();
store.set(
viewableRecordIdComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
emailThreadId,
);
// TODO: Uncomment this once we need to show the thread title in the navigation
// const objectMetadataItem = snapshot
// .getLoadable(objectMetadataItemsState)
// .getValue()
// .find(
// ({ nameSingular }) =>
// nameSingular === CoreObjectNameSingular.MessageThread,
// );
// set(
// commandMenuNavigationMorphItemsState,
// new Map([
// ...snapshot
// .getLoadable(commandMenuNavigationMorphItemsState)
// .getValue(),
// [
// pageComponentInstanceId,
// {
// objectMetadataId: objectMetadataItem?.id,
// recordId: emailThreadId,
// },
// ],
// ]),
// );
navigateCommandMenu({
page: CommandMenuPages.ViewEmailThread,
pageTitle: t`Email Thread`,
pageIcon: IconMail,
pageId: pageComponentInstanceId,
});
},
[navigateCommandMenu, store],
);
return {
openEmailThreadInCommandMenu,
};
};
@@ -1,57 +0,0 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { viewableFrontComponentIdComponentState } from '@/command-menu/pages/front-component/states/viewableFrontComponentIdComponentState';
import { viewableFrontComponentRecordContextComponentState } from '@/command-menu/pages/front-component/states/viewableFrontComponentRecordContextComponentState';
import { useStore } from 'jotai';
import { CommandMenuPages } from 'twenty-shared/types';
import { type IconComponent } from 'twenty-ui/display';
import { v4 } from 'uuid';
export const useOpenFrontComponentInCommandMenu = () => {
const store = useStore();
const { navigateCommandMenu } = useCommandMenu();
const openFrontComponentInCommandMenu = ({
frontComponentId,
pageTitle,
pageIcon,
resetNavigationStack = false,
recordContext,
}: {
frontComponentId: string;
pageTitle: string;
pageIcon: IconComponent;
resetNavigationStack?: boolean;
recordContext?: {
recordId: string;
objectNameSingular: string;
};
}) => {
const pageComponentInstanceId = v4();
store.set(
viewableFrontComponentIdComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
frontComponentId,
);
store.set(
viewableFrontComponentRecordContextComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
recordContext ?? null,
);
navigateCommandMenu({
page: CommandMenuPages.ViewFrontComponent,
pageTitle,
pageIcon,
pageId: pageComponentInstanceId,
resetNavigationStack,
});
};
return {
openFrontComponentInCommandMenu,
};
};
@@ -1,81 +0,0 @@
import { useCommandMenuUpdateNavigationMorphItemsByPage } from '@/command-menu/hooks/useCommandMenuUpdateNavigationMorphItemsByPage';
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useLazyFindManyRecords } from '@/object-record/hooks/useLazyFindManyRecords';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { CommandMenuPages } from 'twenty-shared/types';
import { msg, t } from '@lingui/core/macro';
import { useCallback } from 'react';
import { IconArrowMerge } from 'twenty-ui/display';
import { v4 } from 'uuid';
import { useStore } from 'jotai';
type UseOpenMergeRecordsPageInCommandMenuProps = {
objectNameSingular: string;
objectRecordIds: string[];
};
export const useOpenMergeRecordsPageInCommandMenu = ({
objectNameSingular,
objectRecordIds,
}: UseOpenMergeRecordsPageInCommandMenuProps) => {
const store = useStore();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const { navigateCommandMenu } = useNavigateCommandMenu();
const { updateCommandMenuNavigationMorphItemsByPage } =
useCommandMenuUpdateNavigationMorphItemsByPage();
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const { findManyRecordsLazy } = useLazyFindManyRecords({
objectNameSingular,
filter: {
id: {
in: objectRecordIds,
},
},
});
const openMergeRecordsPageInCommandMenu = useCallback(async () => {
const pageId = v4();
store.set(
contextStoreCurrentObjectMetadataItemIdComponentState.atomFamily({
instanceId: pageId,
}),
objectMetadataItem.id,
);
await updateCommandMenuNavigationMorphItemsByPage({
pageId,
objectMetadataId: objectMetadataItem.id,
objectRecordIds,
});
const { records } = await findManyRecordsLazy();
upsertRecordsInStore({ partialRecords: records ?? [] });
navigateCommandMenu({
page: CommandMenuPages.MergeRecords,
pageTitle: t(msg`Merge records`),
pageIcon: IconArrowMerge,
pageId,
});
}, [
objectMetadataItem.id,
objectRecordIds,
findManyRecordsLazy,
upsertRecordsInStore,
navigateCommandMenu,
updateCommandMenuNavigationMorphItemsByPage,
store,
]);
return {
openMergeRecordsPageInCommandMenu,
};
};
@@ -1,217 +0,0 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { viewableRecordIdComponentState } from '@/command-menu/pages/record-page/states/viewableRecordIdComponentState';
import { viewableRecordNameSingularComponentState } from '@/command-menu/pages/record-page/states/viewableRecordNameSingularComponentState';
import { commandMenuNavigationMorphItemsByPageState } from '@/command-menu/states/commandMenuNavigationMorphItemsByPageState';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { contextStoreCurrentViewTypeComponentState } from '@/context-store/states/contextStoreCurrentViewTypeComponentState';
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { ContextStoreViewType } from '@/context-store/types/ContextStoreViewType';
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { getIconColorForObjectType } from '@/object-metadata/utils/getIconColorForObjectType';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { viewableRecordIdState } from '@/object-record/record-right-drawer/states/viewableRecordIdState';
import { useOpenNewRecordTitleCell } from '@/object-record/record-title-cell/hooks/useOpenNewRecordTitleCell';
import { CommandMenuPages, CoreObjectNameSingular } from 'twenty-shared/types';
import { useRunWorkflowRunOpeningInCommandMenuSideEffects } from '@/workflow/hooks/useRunWorkflowRunOpeningInCommandMenuSideEffects';
import { t } from '@lingui/core/macro';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { v4 } from 'uuid';
export const useOpenRecordInCommandMenu = () => {
const store = useStore();
const { getIcon } = useIcons();
const { navigateCommandMenu } = useCommandMenu();
const { runWorkflowRunOpeningInCommandMenuSideEffects } =
useRunWorkflowRunOpeningInCommandMenuSideEffects();
const { openNewRecordTitleCell } = useOpenNewRecordTitleCell();
const openRecordInCommandMenu = useCallback(
({
recordId,
objectNameSingular,
isNewRecord = false,
resetNavigationStack = false,
}: {
recordId: string;
objectNameSingular: string;
isNewRecord?: boolean;
resetNavigationStack?: boolean;
}) => {
const navigationStack = store.get(commandMenuNavigationStackState.atom);
const currentNavigationStackItem = navigationStack.at(-1);
if (isDefined(currentNavigationStackItem)) {
const currentRecordId = store.get(
viewableRecordIdComponentState.atomFamily({
instanceId: currentNavigationStackItem.pageId,
}),
);
if (currentRecordId === recordId) {
return;
}
}
const pageComponentInstanceId = v4();
store.set(
viewableRecordNameSingularComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
objectNameSingular,
);
store.set(
viewableRecordIdComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
recordId,
);
store.set(viewableRecordIdState.atom, recordId);
const objectMetadataItem = store.get(
objectMetadataItemFamilySelector.selectorFamily({
objectName: objectNameSingular,
objectNameType: 'singular',
}),
);
if (!objectMetadataItem) {
throw new Error(
`No object metadata item found for object name ${objectNameSingular}`,
);
}
store.set(
contextStoreCurrentObjectMetadataItemIdComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
objectMetadataItem.id,
);
store.set(
contextStoreTargetedRecordsRuleComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
{
mode: 'selection',
selectedRecordIds: [recordId],
},
);
store.set(
contextStoreNumberOfSelectedRecordsComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
1,
);
store.set(
contextStoreCurrentViewTypeComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
ContextStoreViewType.ShowPage,
);
store.set(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
store.get(
contextStoreCurrentViewIdComponentState.atomFamily({
instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID,
}),
),
);
store.set(
contextStoreIsPageInEditModeComponentState.atomFamily({
instanceId: pageComponentInstanceId,
}),
store.get(
contextStoreIsPageInEditModeComponentState.atomFamily({
instanceId: MAIN_CONTEXT_STORE_INSTANCE_ID,
}),
),
);
const currentMorphItems = store.get(
commandMenuNavigationMorphItemsByPageState.atom,
);
const morphItemToAdd = {
objectMetadataId: objectMetadataItem.id,
recordId,
};
const newMorphItemsMap = new Map(currentMorphItems);
newMorphItemsMap.set(pageComponentInstanceId, [morphItemToAdd]);
store.set(
commandMenuNavigationMorphItemsByPageState.atom,
newMorphItemsMap,
);
const Icon = objectMetadataItem?.icon
? getIcon(objectMetadataItem.icon)
: getIcon('IconList');
const IconColor = getIconColorForObjectType(
objectMetadataItem.nameSingular,
);
const objectLabelSingular = objectMetadataItem.labelSingular;
navigateCommandMenu({
page: CommandMenuPages.ViewRecord,
pageTitle: isNewRecord
? t`New ${objectLabelSingular}`
: objectLabelSingular,
pageIcon: Icon,
pageIconColor: IconColor,
pageId: pageComponentInstanceId,
resetNavigationStack,
});
if (objectNameSingular === CoreObjectNameSingular.WorkflowRun) {
runWorkflowRunOpeningInCommandMenuSideEffects({
objectMetadataItem,
recordId,
});
}
if (isNewRecord) {
const labelIdentifierField =
getLabelIdentifierFieldMetadataItem(objectMetadataItem);
if (isDefined(labelIdentifierField)) {
openNewRecordTitleCell({
recordId,
fieldName: labelIdentifierField.name,
});
}
}
},
[
getIcon,
navigateCommandMenu,
openNewRecordTitleCell,
runWorkflowRunOpeningInCommandMenuSideEffects,
store,
],
);
return {
openRecordInCommandMenu,
};
};
@@ -1,26 +0,0 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { t } from '@lingui/core/macro';
import { CommandMenuPages } from 'twenty-shared/types';
import { IconSearch } from 'twenty-ui/display';
import { v4 } from 'uuid';
export const useOpenRecordsSearchPageInCommandMenu = () => {
const { navigateCommandMenu } = useCommandMenu();
const isCommandMenuOpened = useAtomStateValue(isCommandMenuOpenedState);
const openRecordsSearchPage = () => {
navigateCommandMenu({
page: CommandMenuPages.SearchRecords,
pageTitle: t`Search`,
pageIcon: IconSearch,
pageId: v4(),
resetNavigationStack: isCommandMenuOpened,
});
};
return {
openRecordsSearchPage,
};
};
@@ -1,29 +0,0 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { CommandMenuPages } from 'twenty-shared/types';
import { msg, t } from '@lingui/core/macro';
import { useCallback } from 'react';
import { IconBoxMultiple } from 'twenty-ui/display';
type UseOpenUpdateMultipleRecordsPageInCommandMenuProps = {
contextStoreInstanceId: string;
};
export const useOpenUpdateMultipleRecordsPageInCommandMenu = ({
contextStoreInstanceId,
}: UseOpenUpdateMultipleRecordsPageInCommandMenuProps) => {
const { navigateCommandMenu } = useNavigateCommandMenu();
const openUpdateMultipleRecordsPageInCommandMenu = useCallback(async () => {
navigateCommandMenu({
page: CommandMenuPages.UpdateRecords,
pageTitle: t(msg`Update records`),
pageIcon: IconBoxMultiple,
pageId: contextStoreInstanceId,
});
}, [navigateCommandMenu, contextStoreInstanceId]);
return {
openUpdateMultipleRecordsPageInCommandMenu,
};
};
@@ -1,21 +0,0 @@
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
import { COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuPreviousComponentInstanceId';
import { useCopyContextStoreStates } from '@/command-menu/hooks/useCopyContextStoreAndActionMenuStates';
import { useResetContextStoreStates } from '@/command-menu/hooks/useResetContextStoreStates';
export const useResetPreviousCommandMenuContext = () => {
const { copyContextStoreStates } = useCopyContextStoreStates();
const { resetContextStoreStates } = useResetContextStoreStates();
const resetPreviousCommandMenuContext = () => {
copyContextStoreStates({
instanceIdToCopyFrom: COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID,
instanceIdToCopyTo: COMMAND_MENU_COMPONENT_INSTANCE_ID,
});
resetContextStoreStates(COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID);
};
return {
resetPreviousCommandMenuContext,
};
};
@@ -1,47 +0,0 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { viewableRichTextComponentState } from '@/command-menu/pages/rich-text-page/states/viewableRichTextComponentState';
import { t } from '@lingui/core/macro';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { CommandMenuPages } from 'twenty-shared/types';
import { IconPencil } from 'twenty-ui/display';
export const useRichTextCommandMenu = () => {
const { navigateCommandMenu, openCommandMenu } = useCommandMenu();
const store = useStore();
const openRichTextInCommandMenu = useCallback(
({
activityId,
activityObjectNameSingular,
}: {
activityId: string;
activityObjectNameSingular: string;
}) => {
store.set(viewableRichTextComponentState.atom, {
activityId,
activityObjectNameSingular,
});
openCommandMenu();
navigateCommandMenu({
page: CommandMenuPages.EditRichText,
pageTitle: t`Rich Text`,
pageIcon: IconPencil,
});
},
[navigateCommandMenu, openCommandMenu, store],
);
const editRichText = useCallback(
(activityId: string, activityObjectNameSingular: string) => {
openRichTextInCommandMenu({ activityId, activityObjectNameSingular });
},
[openRichTextInCommandMenu],
);
return {
editRichText,
};
};
@@ -1,7 +1,7 @@
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
import { COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuPreviousComponentInstanceId';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { hasUserSelectedCommandState } from '@/command-menu/states/hasUserSelectedCommandState';
import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId';
import { SIDE_PANEL_PREVIOUS_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelPreviousComponentInstanceId';
import { sidePanelPageInfoState } from '@/side-panel/states/sidePanelPageInfoState';
import { hasUserSelectedSidePanelListItemState } from '@/side-panel/states/hasUserSelectedSidePanelListItemState';
import { contextStoreAnyFieldFilterValueComponentState } from '@/context-store/states/contextStoreAnyFieldFilterValueComponentState';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
@@ -21,8 +21,8 @@ export const useSetGlobalCommandMenuContext = () => {
const setGlobalCommandMenuContext = useCallback(() => {
store.set(
atom(null, (get, batchSet) => {
const fromId = COMMAND_MENU_COMPONENT_INSTANCE_ID;
const toId = COMMAND_MENU_PREVIOUS_COMPONENT_INSTANCE_ID;
const fromId = SIDE_PANEL_COMPONENT_INSTANCE_ID;
const toId = SIDE_PANEL_PREVIOUS_COMPONENT_INSTANCE_ID;
batchSet(
contextStoreCurrentObjectMetadataItemIdComponentState.atomFamily({
@@ -165,13 +165,13 @@ export const useSetGlobalCommandMenuContext = () => {
ContextStoreViewType.Table,
);
batchSet(commandMenuPageInfoState.atom, {
batchSet(sidePanelPageInfoState.atom, {
title: undefined,
Icon: undefined,
instanceId: '',
});
batchSet(hasUserSelectedCommandState.atom, false);
batchSet(hasUserSelectedSidePanelListItemState.atom, false);
}),
);
}, [store]);
@@ -1,59 +0,0 @@
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { useCallback } from 'react';
import { type IconComponent, IconDotsVertical } from 'twenty-ui/display';
import { useStore } from 'jotai';
export const useUpdateCommandMenuPageInfo = () => {
const store = useStore();
const updateCommandMenuPageInfo = useCallback(
({
pageTitle,
pageIcon,
}: {
pageTitle?: string;
pageIcon?: IconComponent;
}) => {
const commandMenuPageInfo = store.get(commandMenuPageInfoState.atom);
const newCommandMenuPageInfo = {
...commandMenuPageInfo,
title: pageTitle ?? commandMenuPageInfo.title ?? '',
Icon: pageIcon ?? commandMenuPageInfo.Icon ?? IconDotsVertical,
};
store.set(commandMenuPageInfoState.atom, newCommandMenuPageInfo);
const commandMenuNavigationStack = store.get(
commandMenuNavigationStackState.atom,
);
const lastCommandMenuNavigationStackItem =
commandMenuNavigationStack.at(-1);
if (!lastCommandMenuNavigationStackItem) {
return;
}
const newCommandMenuNavigationStack = [
...commandMenuNavigationStack.slice(0, -1),
{
page: lastCommandMenuNavigationStackItem.page,
pageTitle: newCommandMenuPageInfo.title,
pageIcon: newCommandMenuPageInfo.Icon,
pageId: lastCommandMenuNavigationStackItem.pageId,
},
];
store.set(
commandMenuNavigationStackState.atom,
newCommandMenuNavigationStack,
);
},
[store],
);
return {
updateCommandMenuPageInfo,
};
};
@@ -1,254 +0,0 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { commandMenuWorkflowIdComponentState } from '@/command-menu/pages/workflow/states/commandMenuWorkflowIdComponentState';
import { commandMenuWorkflowRunIdComponentState } from '@/command-menu/pages/workflow/states/commandMenuWorkflowRunIdComponentState';
import { commandMenuWorkflowStepIdComponentState } from '@/command-menu/pages/workflow/states/commandMenuWorkflowStepIdComponentState';
import { commandMenuWorkflowVersionIdComponentState } from '@/command-menu/pages/workflow/states/commandMenuWorkflowVersionIdComponentState';
import { type WorkflowRunStepStatus } from '@/workflow/types/Workflow';
import { useSetInitialWorkflowRunRightDrawerTab } from '@/workflow/workflow-diagram/hooks/useSetInitialWorkflowRunRightDrawerTab';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { t } from '@lingui/core/macro';
import { useCallback } from 'react';
import { CommandMenuPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
IconBolt,
type IconComponent,
IconSettingsAutomation,
} from 'twenty-ui/display';
import { v4 } from 'uuid';
import { useStore } from 'jotai';
export const useWorkflowCommandMenu = () => {
const store = useStore();
const { navigateCommandMenu } = useNavigateCommandMenu();
const { setInitialWorkflowRunRightDrawerTab } =
useSetInitialWorkflowRunRightDrawerTab();
const openWorkflowTriggerTypeInCommandMenu = useCallback(
(workflowId: string) => {
const pageId = v4();
store.set(
commandMenuWorkflowIdComponentState.atomFamily({
instanceId: pageId,
}),
workflowId,
);
navigateCommandMenu({
page: CommandMenuPages.WorkflowTriggerSelectType,
pageTitle: t`Trigger Type`,
pageIcon: IconBolt,
pageId,
});
},
[navigateCommandMenu, store],
);
const openWorkflowCreateStepInCommandMenu = useCallback(
(workflowId: string) => {
const pageId = v4();
store.set(
commandMenuWorkflowIdComponentState.atomFamily({
instanceId: pageId,
}),
workflowId,
);
navigateCommandMenu({
page: CommandMenuPages.WorkflowStepCreate,
pageTitle: t`Select Action`,
pageIcon: IconSettingsAutomation,
pageId,
});
},
[navigateCommandMenu, store],
);
const openWorkflowEditStepInCommandMenu = useCallback(
(
workflowId: string,
title: string,
icon: IconComponent,
stepId?: string,
) => {
const pageId = v4();
store.set(
commandMenuWorkflowIdComponentState.atomFamily({
instanceId: pageId,
}),
workflowId,
);
if (isDefined(stepId)) {
store.set(
commandMenuWorkflowStepIdComponentState.atomFamily({
instanceId: pageId,
}),
stepId,
);
store.set(
workflowSelectedNodeComponentState.atomFamily({
instanceId: workflowId,
}),
stepId,
);
}
navigateCommandMenu({
page: CommandMenuPages.WorkflowStepEdit,
pageTitle: title,
pageIcon: icon,
pageId,
});
},
[navigateCommandMenu, store],
);
const openWorkflowEditStepTypeInCommandMenu = useCallback(
(workflowId: string) => {
const pageId = v4();
store.set(
commandMenuWorkflowIdComponentState.atomFamily({
instanceId: pageId,
}),
workflowId,
);
navigateCommandMenu({
page: CommandMenuPages.WorkflowStepEditType,
pageTitle: t`Select action`,
pageIcon: IconSettingsAutomation,
pageId,
});
},
[navigateCommandMenu, store],
);
const openWorkflowViewStepInCommandMenu = useCallback(
({
workflowId,
workflowVersionId,
title,
icon,
stepId,
}: {
workflowId: string;
workflowVersionId: string;
title: string;
icon: IconComponent;
stepId?: string;
}) => {
const pageId = v4();
store.set(
commandMenuWorkflowIdComponentState.atomFamily({
instanceId: pageId,
}),
workflowId,
);
store.set(
commandMenuWorkflowVersionIdComponentState.atomFamily({
instanceId: pageId,
}),
workflowVersionId,
);
if (isDefined(stepId)) {
store.set(
commandMenuWorkflowStepIdComponentState.atomFamily({
instanceId: pageId,
}),
stepId,
);
store.set(
workflowSelectedNodeComponentState.atomFamily({
instanceId: workflowVersionId,
}),
stepId,
);
}
navigateCommandMenu({
page: CommandMenuPages.WorkflowStepView,
pageTitle: title,
pageIcon: icon,
pageId,
});
},
[navigateCommandMenu, store],
);
const openWorkflowRunViewStepInCommandMenu = useCallback(
({
workflowId,
workflowRunId,
title,
icon,
workflowSelectedNode,
stepExecutionStatus,
}: {
workflowId: string;
workflowRunId: string;
title: string;
icon: IconComponent;
workflowSelectedNode: string;
stepExecutionStatus: WorkflowRunStepStatus;
}) => {
const pageId = v4();
store.set(
commandMenuWorkflowIdComponentState.atomFamily({
instanceId: pageId,
}),
workflowId,
);
store.set(
commandMenuWorkflowRunIdComponentState.atomFamily({
instanceId: pageId,
}),
workflowRunId,
);
store.set(
commandMenuWorkflowStepIdComponentState.atomFamily({
instanceId: pageId,
}),
workflowSelectedNode,
);
store.set(
workflowSelectedNodeComponentState.atomFamily({
instanceId: workflowRunId,
}),
workflowSelectedNode,
);
navigateCommandMenu({
page: CommandMenuPages.WorkflowRunStepView,
pageTitle: title,
pageIcon: icon,
pageId,
});
setInitialWorkflowRunRightDrawerTab({
workflowSelectedNode,
stepExecutionStatus,
});
},
[navigateCommandMenu, setInitialWorkflowRunRightDrawerTab, store],
);
return {
openWorkflowTriggerTypeInCommandMenu,
openWorkflowCreateStepInCommandMenu,
openWorkflowEditStepInCommandMenu,
openWorkflowEditStepTypeInCommandMenu,
openWorkflowViewStepInCommandMenu,
openWorkflowRunViewStepInCommandMenu,
};
};
@@ -1,15 +0,0 @@
import { AIChatThreadsList } from '@/ai/components/AIChatThreadsList';
import { styled } from '@linaria/react';
const StyledContainer = styled.div`
height: 100%;
width: 100%;
`;
export const CommandMenuAIChatThreadsPage = () => {
return (
<StyledContainer>
<AIChatThreadsList />
</StyledContainer>
);
};
@@ -1,15 +0,0 @@
import { styled } from '@linaria/react';
import { AIChatTab } from '@/ai/components/AIChatTab';
const StyledContainer = styled.div`
height: 100%;
width: 100%;
`;
export const CommandMenuAskAIPage = () => {
return (
<StyledContainer>
<AIChatTab />
</StyledContainer>
);
};
@@ -1,69 +0,0 @@
import { CalendarEventDetails } from '@/activities/calendar/components/CalendarEventDetails';
import { CalendarEventDetailsEffect } from '@/activities/calendar/components/CalendarEventDetailsEffect';
import { type CalendarEvent } from '@/activities/calendar/types/CalendarEvent';
import { viewableRecordIdComponentState } from '@/command-menu/pages/record-page/states/viewableRecordIdComponentState';
import { CoreObjectNameSingular } from 'twenty-shared/types';
import { useGenerateDepthRecordGqlFieldsFromObject } from '@/object-record/graphql/record-gql-fields/hooks/useGenerateDepthRecordGqlFieldsFromObject';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { PageLayoutType } from '~/generated-metadata/graphql';
export const CommandMenuCalendarEventPage = () => {
const { upsertRecordsInStore } = useUpsertRecordsInStore();
const viewableRecordId = useAtomComponentStateValue(
viewableRecordIdComponentState,
);
const { recordGqlFields } = useGenerateDepthRecordGqlFieldsFromObject({
objectNameSingular: CoreObjectNameSingular.CalendarEvent,
depth: 1,
});
const calendarEventRecordGqlFields = {
...recordGqlFields,
calendarEventParticipants: {
id: true,
person: true,
workspaceMember: true,
isOrganizer: true,
responseStatus: true,
handle: true,
createdAt: true,
calendarEventId: true,
updatedAt: true,
displayName: true,
},
};
const { record: calendarEvent } = useFindOneRecord<CalendarEvent>({
objectNameSingular: CoreObjectNameSingular.CalendarEvent,
objectRecordId: viewableRecordId ?? '',
recordGqlFields: calendarEventRecordGqlFields,
// TODO: this is not executed on sub-sequent runs, make sure that it is intended
onCompleted: (record) => {
upsertRecordsInStore({ partialRecords: [record] });
},
});
if (!calendarEvent) {
return null;
}
return (
<LayoutRenderingProvider
value={{
targetRecordIdentifier: {
id: calendarEvent.id,
targetObjectNameSingular: CoreObjectNameSingular.CalendarEvent,
},
layoutType: PageLayoutType.RECORD_PAGE,
isInRightDrawer: true,
}}
>
<CalendarEventDetailsEffect record={calendarEvent} />
<CalendarEventDetails calendarEvent={calendarEvent} />
</LayoutRenderingProvider>
);
};
@@ -1,41 +0,0 @@
import { styled } from '@linaria/react';
import { IconChevronLeft } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledContainer = styled.div`
align-items: center;
border-bottom: 1px solid ${themeCssVariables.border.color.light};
display: flex;
gap: ${themeCssVariables.spacing[1]};
padding: 0 ${themeCssVariables.spacing[2]};
height: 40px;
`;
const StyledText = styled.span`
color: ${themeCssVariables.font.color.tertiary};
font-size: ${themeCssVariables.font.size.md};
font-weight: ${themeCssVariables.font.weight.medium};
`;
type SidePanelSubPageNavigationHeaderProps = {
title: string;
onBackClick: () => void;
};
export const SidePanelSubPageNavigationHeader = ({
onBackClick,
title,
}: SidePanelSubPageNavigationHeaderProps) => {
return (
<StyledContainer>
<IconButton
onClick={onBackClick}
Icon={IconChevronLeft}
variant="tertiary"
size="small"
/>
<StyledText>{title}</StyledText>
</StyledContainer>
);
};
@@ -1,48 +0,0 @@
import { Suspense, lazy } from 'react';
import { viewableFrontComponentIdComponentState } from '@/command-menu/pages/front-component/states/viewableFrontComponentIdComponentState';
import { viewableFrontComponentRecordContextComponentState } from '@/command-menu/pages/front-component/states/viewableFrontComponentRecordContextComponentState';
import { LayoutRenderingProvider } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { isDefined } from 'twenty-shared/utils';
import { PageLayoutType } from '~/generated-metadata/graphql';
const FrontComponentRenderer = lazy(() =>
import('@/front-components/components/FrontComponentRenderer').then(
(module) => ({ default: module.FrontComponentRenderer }),
),
);
export const CommandMenuFrontComponentPage = () => {
const viewableFrontComponentId = useAtomComponentStateValue(
viewableFrontComponentIdComponentState,
);
const viewableFrontComponentRecordContext = useAtomComponentStateValue(
viewableFrontComponentRecordContextComponentState,
);
if (!isDefined(viewableFrontComponentId)) {
return null;
}
return (
<LayoutRenderingProvider
value={{
targetRecordIdentifier: isDefined(viewableFrontComponentRecordContext)
? {
id: viewableFrontComponentRecordContext.recordId,
targetObjectNameSingular:
viewableFrontComponentRecordContext.objectNameSingular,
}
: undefined,
layoutType: PageLayoutType.DASHBOARD,
isInRightDrawer: true,
}}
>
<Suspense fallback={null}>
<FrontComponentRenderer frontComponentId={viewableFrontComponentId} />
</Suspense>
</LayoutRenderingProvider>
);
};
@@ -1,10 +0,0 @@
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const viewableFrontComponentIdComponentState = createAtomComponentState<
string | null
>({
key: 'command-menu/viewable-front-component-id',
defaultValue: null,
componentInstanceContext: CommandMenuPageComponentInstanceContext,
});
@@ -1,14 +0,0 @@
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
type FrontComponentRecordContext = {
recordId: string;
objectNameSingular: string;
};
export const viewableFrontComponentRecordContextComponentState =
createAtomComponentState<FrontComponentRecordContext | null>({
key: 'command-menu/viewable-front-component-record-context',
defaultValue: null,
componentInstanceContext: CommandMenuPageComponentInstanceContext,
});
@@ -1,46 +0,0 @@
import { styled } from '@linaria/react';
import { useState } from 'react';
import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage';
import { type EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender';
import { Button } from 'twenty-ui/input';
import { IconArrowsVertical } from 'twenty-ui/display';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledButtonContainer = styled.div`
border-bottom: 1px solid ${themeCssVariables.border.color.light};
padding: 16px 24px;
`;
export const CommandMenuMessageThreadIntermediaryMessages = ({
messages,
}: {
messages: EmailThreadMessageWithSender[];
}) => {
const [areMessagesOpen, setAreMessagesOpen] = useState(false);
if (messages.length === 0) {
return null;
}
return areMessagesOpen ? (
messages.map((message) => (
<EmailThreadMessage
key={message.id}
sender={message.sender}
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
/>
))
) : (
<StyledButtonContainer>
<Button
Icon={IconArrowsVertical}
title={`${messages.length} email${messages.length > 1 ? 's' : ''}`}
size="small"
onClick={() => setAreMessagesOpen(true)}
/>
</StyledButtonContainer>
);
};
@@ -1,182 +0,0 @@
import { styled } from '@linaria/react';
import { useEffect, useMemo } from 'react';
import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader';
import { EmailLoader } from '@/activities/emails/components/EmailLoader';
import { EmailThreadHeader } from '@/activities/emails/components/EmailThreadHeader';
import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage';
import { CommandMenuMessageThreadIntermediaryMessages } from '@/command-menu/pages/message-thread/components/CommandMenuMessageThreadIntermediaryMessages';
import { useEmailThreadInCommandMenu } from '@/command-menu/pages/message-thread/hooks/useEmailThreadInCommandMenu';
import { messageThreadComponentState } from '@/command-menu/pages/message-thread/states/messageThreadComponentState';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { t } from '@lingui/core/macro';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { IconArrowBackUp } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
height: 100%;
`;
const StyledContainer = styled.div`
box-sizing: border-box;
display: flex;
flex-direction: column;
flex: 1;
height: 85%;
overflow-y: auto;
`;
const StyledButtonContainer = styled.div`
background: ${themeCssVariables.background.secondary};
border-top: 1px solid ${themeCssVariables.border.color.light};
display: flex;
justify-content: flex-end;
padding: ${themeCssVariables.spacing[2]};
width: 100%;
box-sizing: border-box;
`;
const ALLOWED_REPLY_PROVIDERS = [
ConnectedAccountProvider.GOOGLE,
ConnectedAccountProvider.MICROSOFT,
ConnectedAccountProvider.IMAP_SMTP_CALDAV,
];
export const CommandMenuMessageThreadPage = () => {
const setMessageThread = useSetAtomComponentState(
messageThreadComponentState,
);
const {
thread,
messages,
fetchMoreMessages,
threadLoading,
messageThreadExternalId,
connectedAccountHandle,
messageChannelLoading,
connectedAccountProvider,
lastMessageExternalId,
connectedAccountConnectionParameters,
} = useEmailThreadInCommandMenu();
useEffect(() => {
if (!messages[0]?.messageThread) {
return;
}
setMessageThread(messages[0]?.messageThread);
}, [messages, setMessageThread]);
const messagesCount = messages.length;
const is5OrMoreMessages = messagesCount >= 5;
const firstMessages = messages.slice(
0,
is5OrMoreMessages ? 2 : messagesCount - 1,
);
const intermediaryMessages = is5OrMoreMessages
? messages.slice(2, messagesCount - 1)
: [];
const lastMessage = messages[messagesCount - 1];
const subject = messages[0]?.subject;
const canReply = useMemo(() => {
return (
connectedAccountHandle &&
connectedAccountProvider &&
ALLOWED_REPLY_PROVIDERS.includes(connectedAccountProvider) &&
(connectedAccountProvider !== ConnectedAccountProvider.IMAP_SMTP_CALDAV ||
isDefined(connectedAccountConnectionParameters?.SMTP)) &&
lastMessage &&
messageThreadExternalId != null
);
}, [
connectedAccountConnectionParameters,
connectedAccountHandle,
connectedAccountProvider,
lastMessage,
messageThreadExternalId,
]);
const handleReplyClick = () => {
if (!canReply) {
return;
}
let url: string;
switch (connectedAccountProvider) {
case ConnectedAccountProvider.MICROSOFT:
url = `https://outlook.office.com/mail/deeplink?ItemID=${lastMessageExternalId}`;
window.open(url, '_blank');
break;
case ConnectedAccountProvider.GOOGLE:
url = `https://mail.google.com/mail/?authuser=${connectedAccountHandle}#all/${messageThreadExternalId}`;
window.open(url, '_blank');
break;
case ConnectedAccountProvider.IMAP_SMTP_CALDAV:
throw new Error('Account provider not supported');
case null:
throw new Error('Account provider not provided');
default:
assertUnreachable(connectedAccountProvider);
}
};
if (!thread || !messages.length) {
return null;
}
return (
<StyledWrapper>
<StyledContainer>
{threadLoading ? (
<EmailLoader loadingText={t`Loading thread`} />
) : (
<>
<EmailThreadHeader
subject={subject}
lastMessageSentAt={lastMessage.receivedAt}
/>
{firstMessages.map((message) => (
<EmailThreadMessage
key={message.id}
sender={message.sender}
participants={message.messageParticipants}
body={message.text}
sentAt={message.receivedAt}
/>
))}
<CommandMenuMessageThreadIntermediaryMessages
messages={intermediaryMessages}
/>
<EmailThreadMessage
key={lastMessage.id}
sender={lastMessage.sender}
participants={lastMessage.messageParticipants}
body={lastMessage.text}
sentAt={lastMessage.receivedAt}
isExpanded
/>
<CustomResolverFetchMoreLoader
loading={threadLoading}
onLastRowVisible={fetchMoreMessages}
/>
</>
)}
</StyledContainer>
{canReply && !messageChannelLoading && (
<StyledButtonContainer>
<Button
size="small"
onClick={handleReplyClick}
title={t`Reply`}
Icon={IconArrowBackUp}
disabled={!canReply}
/>
</StyledButtonContainer>
)}
</StyledWrapper>
);
};

Some files were not shown because too many files have changed in this diff Show More