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;
}
};