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:
@@ -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 = ({
|
||||
|
||||
-89
@@ -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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
-64
@@ -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>
|
||||
);
|
||||
};
|
||||
+160
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+63
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+230
@@ -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>
|
||||
);
|
||||
};
|
||||
+5
-4
@@ -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: {
|
||||
|
||||
-97
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
+151
@@ -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;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user