feat: fix Command Menu Side Panel Layout (#15883)

[Figma
Design](https://www.figma.com/design/xt8O9mFeLl46C5InWwoMrN/Twenty?node-id=81380-344641&t=FpjWNOK2gZuDQQfr-0)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Adds a side panel layout for the Command Menu, routes modals into a
local container, updates the top bar and context chips, and standardizes
small button sizes.
> 
> - **Command Menu**:
> - **Side Panel Layout**: Introduces `CommandMenuSidePanelLayout` with
animated width, hosts `CommandMenuRouter`, and provides a modal
container via `ModalContainerContext`.
> - **Top Bar**: Redesign (`CommandMenuTopBar`) with back icon, optional
AI sparkles action, compact height
(`COMMAND_MENU_SEARCH_BAR_HEIGHT=40`), and updated placeholder.
> - **Context Chips**: Adds `CommandMenuLastContextChip` and
`CommandMenuRecordInfo`; extends `CommandMenuContextChip` with `page`
prop; updates `CommandMenuContextChipGroups` to render last chip as
record info when applicable.
> - **Container Simplification**: `CommandMenuContainer` simplified to
just provide contexts and `AgentChatProvider`.
> - **Modal System**:
> - Adds `ModalContainerContext` and updates `Modal` to portal into
provided container; `Modal.Backdrop` supports `isInContainer`.
> - Updates usages (e.g., `UserOrMetadataLoader`, `ActionModal`) to
align with new modal behavior.
> - **Page Integration**:
> - Replaces `PageBody` with `CommandMenuSidePanelLayout` in
`RecordShowPage` and `RecordIndexContainerGater`.
> - Removes global `CommandMenuRouter` from `DefaultLayout` (keeps
keyboard shortcuts).
> - **UI/Styling**:
> - Standardizes several buttons to `size="small"` (e.g., command
actions, open record, options, reply, workflow footer).
> - Adjusts `ShowPageSubContainer` styling when rendered inside command
menu.
>   - Storybook tests updated for new placeholder text.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
81fcaa145618a2fa1c3e11e2dc88fe832c51374c. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
Co-authored-by: Devessier <baptiste@devessier.fr>
Co-authored-by: Aman Raj <92664006+araj00@users.noreply.github.com>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
Co-authored-by: Paul Rastoin <45004772+prastoin@users.noreply.github.com>
This commit is contained in:
Abdul Rahman
2025-11-21 20:42:41 +05:30
committed by GitHub
parent 28b8a4f7ec
commit 04b0a65e73
102 changed files with 1913 additions and 1548 deletions
@@ -0,0 +1,82 @@
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 '@emotion/styled';
import { IconChevronLeft } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { MenuItem } from 'twenty-ui/navigation';
const StyledNavigationIcon = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.secondary};
cursor: pointer;
display: flex;
justify-content: center;
margin-right: ${({ theme }) => theme.spacing(1)};
`;
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={IconChevronLeft}
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,10 +1,6 @@
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { AgentChatProvider } from '@/ai/components/AgentChatProvider';
import { CommandMenuOpenContainer } from '@/command-menu/components/CommandMenuOpenContainer';
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
import { useCommandMenuCloseAnimationCompleteCleanup } from '@/command-menu/hooks/useCommandMenuCloseAnimationCompleteCleanup';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
@@ -12,19 +8,15 @@ import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadat
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { AnimatePresence } from 'framer-motion';
import { useRecoilValue } from 'recoil';
type CommandMenuContainerProps = {
children: React.ReactNode;
};
export const CommandMenuContainer = ({
children,
}: {
children: React.ReactNode;
}) => {
const { commandMenuCloseAnimationCompleteCleanup } =
useCommandMenuCloseAnimationCompleteCleanup();
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
}: CommandMenuContainerProps) => {
const objectMetadataItemId = useRecoilComponentValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
COMMAND_MENU_COMPONENT_INSTANCE_ID,
@@ -46,8 +38,6 @@ export const CommandMenuContainer = ({
currentViewId ?? '',
);
useCommandMenuHotKeys();
return (
<RecordComponentInstanceContextsWrapper componentInstanceId={recordIndexId}>
<ContextStoreComponentInstanceContext.Provider
@@ -56,16 +46,7 @@ export const CommandMenuContainer = ({
<ActionMenuComponentInstanceContext.Provider
value={{ instanceId: COMMAND_MENU_COMPONENT_INSTANCE_ID }}
>
<AgentChatProvider>
<AnimatePresence
mode="wait"
onExitComplete={commandMenuCloseAnimationCompleteCleanup}
>
{isCommandMenuOpened && (
<CommandMenuOpenContainer>{children}</CommandMenuOpenContainer>
)}
</AnimatePresence>
</AgentChatProvider>
<AgentChatProvider>{children}</AgentChatProvider>
</ActionMenuComponentInstanceContext.Provider>
</ContextStoreComponentInstanceContext.Provider>
</RecordComponentInstanceContextsWrapper>
@@ -1,3 +1,4 @@
import { type CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { Fragment } from 'react/jsx-runtime';
@@ -54,6 +55,10 @@ export type CommandMenuContextChipProps = {
testId?: string;
maxWidth?: string;
forceEmptyText?: boolean;
page?: {
page: CommandMenuPages;
pageId: string;
};
};
export const CommandMenuContextChip = ({
@@ -1,89 +0,0 @@
import { COMMAND_MENU_CONTEXT_CHIP_GROUPS_DROPDOWN_ID } from '@/command-menu/constants/CommandMenuContextChipGroupsDropdownId';
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 { isDefined } from 'twenty-shared/utils';
import { MenuItem } from 'twenty-ui/navigation';
import {
CommandMenuContextChip,
type CommandMenuContextChipProps,
} from './CommandMenuContextChip';
export const CommandMenuContextChipGroups = ({
contextChips,
}: {
contextChips: CommandMenuContextChipProps[];
}) => {
const { closeDropdown } = useCloseDropdown();
if (contextChips.length === 0) {
return null;
}
if (contextChips.length < 3) {
return (
<>
{contextChips.map((chip, index) => (
<CommandMenuContextChip
key={index}
maxWidth="180px"
Icons={chip.Icons}
text={chip.text}
onClick={chip.onClick}
/>
))}
</>
);
}
const firstChips = contextChips.slice(0, -1);
const firstThreeChips = firstChips.slice(0, 3);
const lastChip = contextChips.at(-1);
return (
<>
{firstChips.length > 0 && (
<Dropdown
clickableComponent={
<CommandMenuContextChip
Icons={firstThreeChips.map((chip) => chip.Icons?.[0])}
onClick={() => {}}
text={`${firstChips.length}`}
/>
}
dropdownComponents={
<DropdownContent>
<DropdownMenuItemsContainer>
{firstChips.map((chip, index) => (
<MenuItem
key={index}
LeftComponent={chip.Icons}
text={chip.text}
onClick={() => {
closeDropdown(
COMMAND_MENU_CONTEXT_CHIP_GROUPS_DROPDOWN_ID,
);
chip.onClick?.();
}}
/>
))}
</DropdownMenuItemsContainer>
</DropdownContent>
}
dropdownId={COMMAND_MENU_CONTEXT_CHIP_GROUPS_DROPDOWN_ID}
dropdownPlacement="bottom-start"
></Dropdown>
)}
{isDefined(lastChip) && (
<CommandMenuContextChip
Icons={lastChip.Icons}
text={lastChip.text}
onClick={lastChip.onClick}
maxWidth="180px"
/>
)}
</>
);
};
@@ -1,64 +0,0 @@
import { CommandMenuContextChipGroups } from '@/command-menu/components/CommandMenuContextChipGroups';
import { CommandMenuContextRecordChipAvatars } from '@/command-menu/components/CommandMenuContextRecordChipAvatars';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { getSelectedRecordsContextText } from '@/command-menu/utils/getRecordContextText';
import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { type CommandMenuContextChipProps } from './CommandMenuContextChip';
import { isDefined } from 'twenty-shared/utils';
export const CommandMenuContextChipGroupsWithRecordSelection = ({
contextChips,
objectMetadataItemId,
}: {
contextChips: CommandMenuContextChipProps[];
objectMetadataItemId: string;
}) => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataItemId,
});
const { records, loading, totalCount } =
useFindManyRecordsSelectedInContextStore({
limit: 3,
});
const { openCommandMenu } = useCommandMenu();
if (loading) {
return null;
}
const Avatars = records.map((record) => (
<CommandMenuContextRecordChipAvatars
objectMetadataItem={objectMetadataItem}
key={record.id}
record={record}
/>
));
const recordSelectionContextChip =
totalCount && records.length > 0
? {
text: getSelectedRecordsContextText(
objectMetadataItem,
records,
totalCount,
),
Icons: Avatars,
onClick: contextChips.length > 0 ? openCommandMenu : undefined,
withIconBackground: false,
}
: undefined;
const contextChipsWithRecordSelection = [
recordSelectionContextChip,
...contextChips,
].filter(isDefined);
return (
<CommandMenuContextChipGroups
contextChips={contextChipsWithRecordSelection}
/>
);
};
@@ -0,0 +1,68 @@
import { CommandMenuPageLayoutInfo } from '@/command-menu/components/CommandMenuPageLayoutInfo';
import { CommandMenuRecordInfo } from '@/command-menu/components/CommandMenuRecordInfo';
import { CommandMenuWorkflowStepInfo } from '@/command-menu/components/CommandMenuWorkflowStepInfo';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared/utils';
import { OverflowingTextWithTooltip } from 'twenty-ui/display';
import { type CommandMenuContextChipProps } from './CommandMenuContextChip';
const StyledPageTitle = styled.div`
color: ${({ theme }) => theme.font.color.primary};
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
`;
type CommandMenuPageInfoProps = {
pageChip: CommandMenuContextChipProps | undefined;
};
export const CommandMenuPageInfo = ({ pageChip }: CommandMenuPageInfoProps) => {
if (!isDefined(pageChip)) {
return null;
}
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
commandMenuPageInstanceId={pageChip.page.pageId}
/>
);
}
const isPageLayoutPage = pageChip.page?.page
? [
CommandMenuPages.PageLayoutWidgetTypeSelect,
CommandMenuPages.PageLayoutGraphTypeSelect,
CommandMenuPages.PageLayoutGraphFilter,
CommandMenuPages.PageLayoutIframeSettings,
CommandMenuPages.PageLayoutTabSettings,
].includes(pageChip.page?.page)
: false;
if (isPageLayoutPage) {
return <CommandMenuPageLayoutInfo />;
}
return (
<StyledPageTitle>
<OverflowingTextWithTooltip text={pageChip.text ?? ''} />
</StyledPageTitle>
);
};
@@ -0,0 +1,160 @@
import { usePageLayoutHeaderInfo } from '@/command-menu/components/hooks/usePageLayoutHeaderInfo';
import { useUpdateCommandMenuPageInfo } from '@/command-menu/hooks/useUpdateCommandMenuPageInfo';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
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 { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { isNonEmptyString } from '@sniptt/guards';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
const StyledPageLayoutInfoContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledPageLayoutIcon = styled.div<{ iconColor: string }>`
align-items: center;
background: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ iconColor }) => iconColor};
display: flex;
flex-shrink: 0;
justify-content: center;
padding: ${({ theme }) => theme.spacing(1)};
`;
const StyledPageLayoutTitleContainer = styled.div`
align-items: center;
display: flex;
flex: 1;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
min-width: 0;
`;
const StyledPageLayoutType = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
white-space: nowrap;
`;
export const CommandMenuPageLayoutInfo = () => {
const theme = useTheme();
const { getIcon } = useIcons();
const commandMenuPage = useRecoilValue(commandMenuPageState);
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const draftPageLayout = useRecoilComponentValue(
pageLayoutDraftComponentState,
pageLayoutId,
);
const pageLayoutEditingWidgetId = useRecoilComponentValue(
pageLayoutEditingWidgetIdComponentState,
pageLayoutId,
);
const [openTabId] = useRecoilComponentState(
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,
pageLayoutEditingWidgetId,
openTabId,
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 (
<StyledPageLayoutInfoContainer>
{isDefined(headerIcon) && (
<StyledPageLayoutIcon iconColor={headerIconColor}>
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
</StyledPageLayoutIcon>
)}
<StyledPageLayoutTitleContainer>
<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}
/>
</StyledPageLayoutTitleContainer>
{headerType && <StyledPageLayoutType>{headerType}</StyledPageLayoutType>}
</StyledPageLayoutInfoContainer>
);
};
@@ -0,0 +1,162 @@
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/recordStoreIdentifierSelector';
import { RecordTitleCell } from '@/object-record/record-title-cell/components/RecordTitleCell';
import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import styled from '@emotion/styled';
import { Trans } from '@lingui/react/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { Avatar } from 'twenty-ui/display';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
const StyledRecordInfoContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledRecordAvatar = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
flex-shrink: 0;
justify-content: center;
padding: ${({ theme }) => theme.spacing(1)};
`;
const StyledRecordTitleContainer = styled.div`
align-items: center;
display: flex;
flex: 1;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
min-width: 0;
`;
const StyledRecordDate = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
white-space: nowrap;
`;
export const CommandMenuRecordInfo = ({
commandMenuPageInstanceId,
}: {
commandMenuPageInstanceId: string;
}) => {
const viewableRecordNameSingular = useRecoilComponentValue(
viewableRecordNameSingularComponentState,
commandMenuPageInstanceId,
);
const viewableRecordId = useRecoilComponentValue(
viewableRecordIdComponentState,
commandMenuPageInstanceId,
);
const { objectNameSingular, objectRecordId } = useRecordShowPage(
viewableRecordNameSingular!,
viewableRecordId!,
);
const recordCreatedAt = useRecoilValue<string | null>(
recordStoreFamilySelector({
recordId: objectRecordId,
fieldName: 'createdAt',
}),
);
const recordIdentifier = useRecoilValue(
recordStoreIdentifierFamilySelector({
recordId: objectRecordId,
}),
);
const { localeCatalog } = useRecoilValue(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,
objectRecordId,
});
const fieldDefinition = {
type: labelIdentifierFieldMetadataItem?.type ?? FieldMetadataType.TEXT,
iconName: '',
fieldMetadataId: labelIdentifierFieldMetadataItem?.id ?? '',
label: labelIdentifierFieldMetadataItem?.label ?? '',
metadata: {
fieldName: labelIdentifierFieldMetadataItem?.name ?? '',
objectMetadataNameSingular: objectNameSingular,
},
defaultValue: labelIdentifierFieldMetadataItem?.defaultValue,
};
return (
<StyledRecordInfoContainer>
{recordIdentifier && (
<StyledRecordAvatar>
<Avatar
avatarUrl={recordIdentifier.avatarUrl}
placeholder={recordIdentifier.name}
placeholderColorSeed={objectRecordId}
size="md"
type={recordIdentifier.avatarType}
/>
</StyledRecordAvatar>
)}
<StyledRecordTitleContainer>
<FieldContext.Provider
value={{
recordId: objectRecordId,
isLabelIdentifier: false,
fieldDefinition,
useUpdateRecord: useUpdateOneObjectRecordMutation,
isCentered: false,
isDisplayModeFixHeight: true,
isRecordFieldReadOnly: isTitleReadOnly,
}}
>
<RecordTitleCell
sizeVariant="sm"
containerType={RecordTitleCellContainerType.PageHeader}
/>
</FieldContext.Provider>
</StyledRecordTitleContainer>
{beautifiedCreatedAt && (
<StyledRecordDate>
<Trans>Created {beautifiedCreatedAt}</Trans>
</StyledRecordDate>
)}
</StyledRecordInfoContainer>
);
};
@@ -1,49 +1,50 @@
import { CommandMenuContextChip } from '@/command-menu/components/CommandMenuContextChip';
import { CommandMenuContextChipGroups } from '@/command-menu/components/CommandMenuContextChipGroups';
import { CommandMenuContextChipGroupsWithRecordSelection } from '@/command-menu/components/CommandMenuContextChipGroupsWithRecordSelection';
import { CommandMenuBackButton } from '@/command-menu/components/CommandMenuBackButton';
import { CommandMenuPageInfo } from '@/command-menu/components/CommandMenuPageInfo';
import { CommandMenuTopBarInputFocusEffect } from '@/command-menu/components/CommandMenuTopBarInputFocusEffect';
import { COMMAND_MENU_COMPONENT_INSTANCE_ID } from '@/command-menu/constants/CommandMenuComponentInstanceId';
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 { COMMAND_MENU_SEARCH_BAR_PADDING } from '@/command-menu/constants/CommandMenuSearchBarPadding';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useCommandMenuContextChips } from '@/command-menu/hooks/useCommandMenuContextChips';
import { useCommandMenuHistory } from '@/command-menu/hooks/useCommandMenuHistory';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { contextStoreCurrentObjectMetadataItemIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataItemIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { useRecoilState, useRecoilValue } from 'recoil';
import { AppBasePath } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronLeft, IconX } from 'twenty-ui/display';
import { Button } from 'twenty-ui/input';
import { getOsControlSymbol, useIsMobile } from 'twenty-ui/utilities';
import { IconX } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { useIsMobile } from 'twenty-ui/utilities';
const StyledInputContainer = styled.div`
const StyledInputContainer = styled.div<{ isMobile: boolean }>`
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.lighter};
background-color: ${({ theme }) => theme.background.secondary};
border: none;
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: 0;
box-sizing: border-box;
display: flex;
justify-content: space-between;
font-size: ${({ theme }) => theme.font.size.lg};
height: ${COMMAND_MENU_SEARCH_BAR_HEIGHT}px;
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 ${({ theme }) => theme.spacing(COMMAND_MENU_SEARCH_BAR_PADDING)};
gap: ${({ theme }) => theme.spacing(1)};
gap: ${({ theme }) => theme.spacing(4)};
flex-shrink: 0;
justify-content: space-between;
`;
const StyledInput = styled.input`
@@ -69,10 +70,8 @@ const StyledContentContainer = styled.div`
display: flex;
flex: 1;
gap: ${({ theme }) => theme.spacing(1)};
`;
const StyledCloseButtonWrapper = styled.div<{ isVisible: boolean }>`
visibility: ${({ isVisible }) => (isVisible ? 'visible' : 'hidden')};
min-width: 0;
overflow: hidden;
`;
export const CommandMenuTopBar = () => {
@@ -91,83 +90,75 @@ export const CommandMenuTopBar = () => {
const { closeCommandMenu } = useCommandMenu();
const { goBackFromCommandMenu } = useCommandMenuHistory();
const contextStoreCurrentObjectMetadataItemId = useRecoilComponentValue(
contextStoreCurrentObjectMetadataItemIdComponentState,
COMMAND_MENU_COMPONENT_INSTANCE_ID,
);
const commandMenuPage = useRecoilValue(commandMenuPageState);
const commandMenuNavigationStack = useRecoilValue(
commandMenuNavigationStackState,
);
const theme = useTheme();
const { contextChips } = useCommandMenuContextChips();
const location = useLocation();
const isButtonVisible =
!location.pathname.startsWith(`${AppBasePath.Root}objects/`) &&
!location.pathname.startsWith(`${AppBasePath.Root}object/`);
const canGoBack = commandMenuNavigationStack.length > 1;
const backButtonAnimationDuration =
contextChips.length > 0 ? theme.animation.duration.instant : 0;
const shouldShowCloseButton =
!isMobile && commandMenuNavigationStack.length === 1;
const shouldShowBackButton = canGoBack;
const lastChip = contextChips.at(-1);
return (
<StyledInputContainer>
<StyledInputContainer isMobile={isMobile}>
<StyledContentContainer>
<AnimatePresence>
{commandMenuPage !== CommandMenuPages.Root && (
{shouldShowBackButton && (
<motion.div
exit={{ opacity: 0, width: 0 }}
transition={{
duration: backButtonAnimationDuration,
duration: theme.animation.duration.instant,
}}
>
<CommandMenuContextChip
Icons={[<IconChevronLeft size={theme.icon.size.sm} />]}
onClick={goBackFromCommandMenu}
testId="command-menu-go-back-button"
forceEmptyText={true}
<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>
{isDefined(contextStoreCurrentObjectMetadataItemId) &&
commandMenuPage !== CommandMenuPages.SearchRecords ? (
<CommandMenuContextChipGroupsWithRecordSelection
contextChips={contextChips}
objectMetadataItemId={contextStoreCurrentObjectMetadataItemId}
/>
) : (
<CommandMenuContextChipGroups contextChips={contextChips} />
)}
{lastChip &&
commandMenuPage !== CommandMenuPages.Root &&
commandMenuPage !== CommandMenuPages.SearchRecords && (
<CommandMenuPageInfo pageChip={lastChip} />
)}
{(commandMenuPage === CommandMenuPages.Root ||
commandMenuPage === CommandMenuPages.SearchRecords) && (
<>
<StyledInput
data-testid="command-menu-search-input"
ref={inputRef}
value={commandMenuSearch}
placeholder={t`Type anything`}
placeholder={t`Type anything...`}
onChange={handleSearchChange}
/>
<CommandMenuTopBarInputFocusEffect inputRef={inputRef} />
</>
)}
</StyledContentContainer>
{!isMobile && (
<StyledCloseButtonWrapper isVisible={isButtonVisible}>
<Button
Icon={IconX}
dataTestId="page-header-close-command-menu-button"
size="small"
variant="secondary"
accent="default"
hotkeys={[getOsControlSymbol(), 'K']}
ariaLabel="Close command menu"
onClick={closeCommandMenu}
/>
</StyledCloseButtonWrapper>
)}
<CommandMenuTopBarRightCornerIcon />
</StyledInputContainer>
);
};
@@ -0,0 +1,63 @@
import { useCommandMenuHistory } from '@/command-menu/hooks/useCommandMenuHistory';
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { IconHandMove, IconSparkles } from 'twenty-ui/display';
import { IconButton } from 'twenty-ui/input';
import { useIsMobile } from 'twenty-ui/utilities';
import { FeatureFlagKey } from '~/generated/graphql';
const StyledIconButton = styled(IconButton)`
color: ${({ theme }) => theme.font.color.secondary};
`;
export const CommandMenuTopBarRightCornerIcon = () => {
const isMobile = useIsMobile();
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
const commandMenuPage = useRecoilValue(commandMenuPageState);
const commandMenuNavigationStack = useRecoilValue(
commandMenuNavigationStackState,
);
const { goBackFromCommandMenu } = useCommandMenuHistory();
const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
if (isMobile || !isAiEnabled) {
return null;
}
const isOnAskAIPage = [
CommandMenuPages.AskAI,
CommandMenuPages.ViewPreviousAIChats,
].includes(commandMenuPage);
const canGoBack = commandMenuNavigationStack.length > 1;
if (!isOnAskAIPage) {
return (
<StyledIconButton
onClick={() => openAskAIPage({ resetNavigationStack: false })}
Icon={IconSparkles}
variant="tertiary"
size="small"
/>
);
}
if (!canGoBack) {
return null;
}
return (
<StyledIconButton
Icon={IconHandMove}
size="small"
variant="tertiary"
onClick={goBackFromCommandMenu}
/>
);
};
@@ -0,0 +1,230 @@
import { useUpdateCommandMenuPageInfo } from '@/command-menu/hooks/useUpdateCommandMenuPageInfo';
import { commandMenuWorkflowIdComponentState } from '@/command-menu/pages/workflow/states/commandMenuWorkflowIdComponentState';
import { commandMenuWorkflowStepIdComponentState } from '@/command-menu/pages/workflow/states/commandMenuWorkflowStepIdComponentState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { TitleInput } from '@/ui/input/components/TitleInput';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { type WorkflowVersion } from '@/workflow/types/Workflow';
import { getAgentIdFromStep } from '@/workflow/utils/getAgentIdFromStep';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
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 { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { TRIGGER_STEP_ID } from 'twenty-shared/workflow';
import { useIcons } from 'twenty-ui/display';
const StyledWorkflowStepInfoContainer = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(0.5)};
`;
const StyledWorkflowStepIcon = styled.div<{ iconColor: string }>`
align-items: center;
background: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.border.radius.sm};
color: ${({ iconColor }) => iconColor};
display: flex;
flex-shrink: 0;
justify-content: center;
padding: ${({ theme }) => theme.spacing(1)};
`;
const StyledWorkflowStepTitleContainer = styled.div`
align-items: center;
display: flex;
flex: 1;
font-size: ${({ theme }) => theme.font.size.sm};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
min-width: 0;
`;
const StyledWorkflowStepType = styled.div`
color: ${({ theme }) => theme.font.color.tertiary};
font-size: ${({ theme }) => theme.font.size.sm};
white-space: nowrap;
`;
export const CommandMenuWorkflowStepInfo = ({
commandMenuPageInstanceId,
}: {
commandMenuPageInstanceId: string;
}) => {
const theme = useTheme();
const { getIcon } = useIcons();
const commandMenuPage = useRecoilValue(commandMenuPageState);
const workflowId = useRecoilComponentValue(
commandMenuWorkflowIdComponentState,
commandMenuPageInstanceId,
);
const workflowStepId = useRecoilComponentValue(
commandMenuWorkflowStepIdComponentState,
commandMenuPageInstanceId,
);
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
const isReadonly =
commandMenuPage === CommandMenuPages.WorkflowStepView ||
commandMenuPage === CommandMenuPages.WorkflowRunStepView;
const { updateCommandMenuPageInfo } = useUpdateCommandMenuPageInfo();
const { updateWorkflowVersionStep } = useUpdateWorkflowVersionStep();
const { updateOneRecord: updateOneWorkflowVersion } =
useUpdateOneRecord<WorkflowVersion>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const {
trigger,
steps,
id: workflowVersionId,
} = workflowWithCurrentVersion?.currentVersion ?? {
trigger: null,
steps: null,
id: undefined,
};
const isTriggerStep = workflowStepId === TRIGGER_STEP_ID;
const stepDefinition =
isDefined(workflowStepId) && isDefined(trigger)
? isTriggerStep || isDefined(steps)
? getStepDefinitionOrThrow({
stepId: workflowStepId,
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'
? 'Launch manually'
: 'Trigger'))
: (stepDefinition.definition.name ?? '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(workflowStepId) ||
!isDefined(workflowWithCurrentVersion?.currentVersion) ||
!isDefined(stepDefinition) ||
!isDefined(stepDefinition.definition)
) {
return null;
}
const headerIcon = isTrigger
? getTriggerIcon(stepDefinition.definition)
: getActionIcon(stepDefinition.definition.type);
const headerIconColor = isTrigger
? getTriggerIconColor({
theme,
triggerType: stepDefinition.definition.type,
})
: getActionIconColorOrThrow({
theme,
actionType: stepDefinition.definition.type,
});
const headerType = isTrigger ? 'Trigger' : 'Action';
const Icon = getIcon(headerIcon ?? 'IconDefault');
const saveTitle = async () => {
if (!isDefined(workflowVersionId)) {
return;
}
updateCommandMenuPageInfo({
pageTitle: title,
pageIcon: Icon,
});
if (isTrigger) {
await updateOneWorkflowVersion({
idToUpdate: workflowVersionId,
updateOneRecordInput: {
trigger: {
...stepDefinition.definition,
name: title,
} as typeof stepDefinition.definition,
},
});
} else {
await updateWorkflowVersionStep({
workflowVersionId,
step: {
...stepDefinition.definition,
name: title,
},
});
if (isDefined(agentId)) {
await updateAgentLabel(title);
}
}
};
return (
<StyledWorkflowStepInfoContainer>
{headerIcon && (
<StyledWorkflowStepIcon iconColor={headerIconColor}>
<Icon size={theme.icon.size.md} stroke={theme.icon.stroke.sm} />
</StyledWorkflowStepIcon>
)}
<StyledWorkflowStepTitleContainer>
<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}
/>
</StyledWorkflowStepTitleContainer>
<StyledWorkflowStepType>
{isTrigger ? t`Trigger` : t`Action`}
</StyledWorkflowStepType>
</StyledWorkflowStepInfoContainer>
);
};
@@ -146,7 +146,7 @@ export const LimitedPermissions: Story = {
export const MatchingNavigate: Story = {
play: async () => {
const canvas = within(document.body);
const searchInput = await canvas.findByPlaceholderText('Type anything');
const searchInput = await canvas.findByTestId('command-menu-search-input');
await sleep(openTimeout);
await userEvent.type(searchInput, 'ta');
expect(await canvas.findByText('Go to Tasks')).toBeVisible();
@@ -156,7 +156,7 @@ export const MatchingNavigate: Story = {
export const MatchingNavigateShortcuts: Story = {
play: async () => {
const canvas = within(document.body);
const searchInput = await canvas.findByPlaceholderText('Type anything');
const searchInput = await canvas.findByTestId('command-menu-search-input');
await sleep(openTimeout);
await userEvent.type(searchInput, 'gp');
expect(await canvas.findByText('Go to People')).toBeVisible();
@@ -169,7 +169,7 @@ export const MatchingNavigateShortcuts: Story = {
// const canvas = within(document.body);
// const searchRecordsButton = await canvas.findByText('Search records');
// await userEvent.click(searchRecordsButton);
// const searchInput = await canvas.findByPlaceholderText('Type anything');
// const searchInput = await canvas.findByPlaceholderText('Type anything...');
// await sleep(openTimeout);
// await userEvent.type(searchInput, 'n');
// expect(await canvas.findByText('Linkedin')).toBeVisible();
@@ -181,7 +181,7 @@ export const MatchingNavigateShortcuts: Story = {
export const NoResultsSearchFallback: Story = {
play: async () => {
const canvas = within(document.body);
const searchInput = await canvas.findByPlaceholderText('Type anything');
const searchInput = await canvas.findByTestId('command-menu-search-input');
await sleep(openTimeout);
await userEvent.type(searchInput, 'input without results');
expect(await canvas.findByText('No results found')).toBeVisible();
@@ -191,6 +191,7 @@ export const NoResultsSearchFallback: Story = {
parameters: {
msw: {
handlers: [
...graphqlMocks.handlers,
graphql.query('Search', () => {
return HttpResponse.json({
data: {
@@ -1,97 +0,0 @@
import { type Meta, type StoryObj } from '@storybook/react';
import styled from '@emotion/styled';
import { CommandMenuContextChipGroups } from '../CommandMenuContextChipGroups';
import { ComponentDecorator } from 'twenty-ui/testing';
import {
IconBuildingSkyscraper,
IconSearch,
IconSettingsAutomation,
IconUser,
} from 'twenty-ui/display';
const StyledContainer = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(1)};
`;
const meta: Meta<typeof CommandMenuContextChipGroups> = {
title: 'Modules/CommandMenu/CommandMenuContextChipGroups',
component: CommandMenuContextChipGroups,
decorators: [
(Story) => <StyledContainer>{Story()}</StyledContainer>,
ComponentDecorator,
],
};
export default meta;
type Story = StoryObj<typeof CommandMenuContextChipGroups>;
export const Default: Story = {
args: {
contextChips: [
{
Icons: [<IconBuildingSkyscraper size={16} />],
text: 'Company',
},
{
Icons: [<IconUser size={16} />],
text: 'Person',
},
],
},
};
export const SingleChip: Story = {
args: {
contextChips: [
{
Icons: [<IconUser size={16} />],
text: 'Person',
},
],
},
};
export const ThreeChipsWithIcons: Story = {
args: {
contextChips: [
{
Icons: [<IconBuildingSkyscraper size={16} />],
text: 'Company',
},
{
Icons: [<IconUser size={16} />],
text: 'Person',
},
{
Icons: [<IconSettingsAutomation size={16} />],
text: 'Settings',
},
],
},
};
export const FourChipsWithIcons: Story = {
args: {
contextChips: [
{
Icons: [<IconBuildingSkyscraper size={16} />],
text: 'Company',
},
{
Icons: [<IconUser size={16} />],
text: 'Person',
},
{
Icons: [<IconSettingsAutomation size={16} />],
text: 'Settings',
},
{
Icons: [<IconSearch size={16} />],
text: 'Search',
},
],
},
};
@@ -0,0 +1,151 @@
import { GRAPH_TYPE_INFORMATION } from '@/command-menu/pages/page-layout/constants/GraphTypeInformation';
import { isChartWidget } from '@/command-menu/pages/page-layout/utils/isChartWidget';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import {
IconAppWindow,
IconFrame,
type IconComponent,
} from 'twenty-ui/display';
import { type PageLayoutWidget } from '~/generated/graphql';
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 = useTheme();
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 (!isChartWidget(widgetInEditMode)) {
return null;
}
const currentGraphType = widgetInEditMode.configuration.graphType;
const graphTypeInfo = GRAPH_TYPE_INFORMATION[currentGraphType];
const graphTypeLabel = t(graphTypeInfo.label);
const headerType =
commandMenuPage === CommandMenuPages.PageLayoutGraphFilter
? t`${graphTypeLabel} Chart`
: 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,
};
}
default:
return null;
}
};