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
@@ -1,5 +1,4 @@
import { type ReactNode, useContext } from 'react';
import { createPortal } from 'react-dom';
import { ActionDisplay } from '@/action-menu/actions/display/components/ActionDisplay';
import { ActionConfigContext } from '@/action-menu/contexts/ActionConfigContext';
@@ -63,19 +62,17 @@ export const ActionModal = ({
return (
<>
<ActionDisplay onClick={handleClick} />
{isModalOpened &&
createPortal(
<ConfirmationModal
modalId={modalId}
title={title}
subtitle={subtitle}
onConfirmClick={handleConfirmClick}
confirmButtonText={confirmButtonText}
confirmButtonAccent={confirmButtonAccent}
loading={isLoading}
/>,
document.body,
)}
{isModalOpened && (
<ConfirmationModal
modalId={modalId}
title={title}
subtitle={subtitle}
onConfirmClick={handleConfirmClick}
confirmButtonText={confirmButtonText}
confirmButtonAccent={confirmButtonAccent}
loading={isLoading}
/>
)}
</>
);
};
@@ -2,11 +2,12 @@ import { ActionDisplay } from '@/action-menu/actions/display/components/ActionDi
import { ActionConfigContext } from '@/action-menu/contexts/ActionConfigContext';
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { commandMenuSearchState } from '@/command-menu/states/commandMenuSearchState';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { type CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { type MessageDescriptor } from '@lingui/core';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { useSetRecoilState } from 'recoil';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { type IconComponent } from 'twenty-ui/display';
export const ActionOpenSidePanelPage = ({
@@ -28,6 +29,8 @@ export const ActionOpenSidePanelPage = ({
const setCommandMenuSearchState = useSetRecoilState(commandMenuSearchState);
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
if (!actionConfig) {
return null;
}
@@ -39,6 +42,7 @@ export const ActionOpenSidePanelPage = ({
page,
pageTitle: t(pageTitle),
pageIcon,
resetNavigationStack: isCommandMenuOpened,
});
if (shouldResetSearchState) {
@@ -1,4 +1,4 @@
import { ActionModal } from '@/action-menu/actions/components/ActionModal';
import { Action } from '@/action-menu/actions/components/Action';
import { ActionConfigContext } from '@/action-menu/contexts/ActionConfigContext';
import { computeProgressText } from '@/action-menu/utils/computeProgressText';
import { getActionLabel } from '@/action-menu/utils/getActionLabel';
@@ -14,7 +14,6 @@ import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { t } from '@lingui/core/macro';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
@@ -92,12 +91,7 @@ export const DeleteMultipleRecordsAction = () => {
return (
<ActionConfigContext.Provider value={actionConfigWithProgress}>
<ActionModal
title={t`Delete Records`}
subtitle={t`Are you sure you want to delete these records? They can be recovered from the Command menu.`}
onConfirmClick={handleDeleteClick}
confirmButtonText={t`Delete Records`}
/>
<Action onClick={handleDeleteClick} />
</ActionConfigContext.Provider>
);
};
@@ -1,11 +1,10 @@
import { ActionModal } from '@/action-menu/actions/components/ActionModal';
import { Action } from '@/action-menu/actions/components/Action';
import { useSelectedRecordIdOrThrow } from '@/action-menu/actions/record-actions/single-record/hooks/useSelectedRecordIdOrThrow';
import { useDeleteFavorite } from '@/favorites/hooks/useDeleteFavorite';
import { useFavorites } from '@/favorites/hooks/useFavorites';
import { useDeleteOneRecord } from '@/object-record/hooks/useDeleteOneRecord';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { useResetTableRowSelection } from '@/object-record/record-table/hooks/internal/useResetTableRowSelection';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
export const DeleteSingleRecordAction = () => {
@@ -37,12 +36,5 @@ export const DeleteSingleRecordAction = () => {
await deleteOneRecord(recordId);
};
return (
<ActionModal
title={t`Delete Record`}
subtitle={t`Are you sure you want to delete this record? It can be recovered from the Command menu.`}
onConfirmClick={handleDeleteClick}
confirmButtonText={t`Delete Record`}
/>
);
return <Action onClick={handleDeleteClick} />;
};
@@ -25,7 +25,7 @@ export const CmdEnterActionButton = ({
title={title}
variant={disabled ? 'secondary' : 'primary'}
accent="blue"
size="medium"
size="small"
onClick={onClick}
disabled={disabled}
hotkeys={[getOsControlSymbol(), '⏎']}
@@ -66,7 +66,11 @@ export const CommandMenuActionMenuDropdown = () => {
dropdownId={dropdownId}
data-select-disable
clickableComponent={
<Button title={t`Options`} hotkeys={[getOsControlSymbol(), 'O']} />
<Button
size="small"
title={t`Options`}
hotkeys={[getOsControlSymbol(), 'O']}
/>
}
dropdownPlacement="top-end"
dropdownOffset={{ y: parseInt(theme.spacing(2), 10) }}
@@ -135,7 +135,7 @@ export const RecordShowRightDrawerOpenRecordButton = ({
title={t`Open`}
variant="primary"
accent="blue"
size="medium"
size="small"
Icon={IconBrowserMaximize}
hotkeys={[getOsControlSymbol(), '⏎']}
onClick={handleOpenRecord}
@@ -90,7 +90,10 @@ export const AIChatThreadGroup = ({
<StyledThreadItem
onClick={() => {
setCurrentAIChatThread(thread.id);
openAskAIPage(thread.title);
openAskAIPage({
pageTitle: thread.title,
resetNavigationStack: false,
});
}}
key={thread.id}
>
@@ -10,7 +10,7 @@ export const useCreateNewAIChatThread = () => {
const [createChatThread] = useCreateChatThreadMutation({
onCompleted: (data) => {
setCurrentAIChatThread(data.createChatThread.id);
openAskAIPage();
openAskAIPage({ resetNavigationStack: false });
},
});
@@ -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;
}
};
@@ -0,0 +1,2 @@
export const COMMAND_MENU_NAVIGATION_HISTORY_DROPDOWN_ID =
'command-menu-navigation-history-dropdown';
@@ -1 +1 @@
export const COMMAND_MENU_SEARCH_BAR_HEIGHT = 56;
export const COMMAND_MENU_SEARCH_BAR_HEIGHT = 40;
@@ -0,0 +1 @@
export const COMMAND_MENU_SEARCH_BAR_HEIGHT_MOBILE = 52;
@@ -0,0 +1 @@
export const COMMAND_MENU_SIDE_PANEL_WIDTH = 400;
@@ -8,6 +8,8 @@ import { commandMenuNavigationStackState } from '@/command-menu/states/commandMe
import { commandMenuPageInfoState } from '@/command-menu/states/commandMenuPageInfoState';
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { IconDotsVertical } from 'twenty-ui/display';
const Wrapper = ({ children }: { children: React.ReactNode }) => (
<RecoilRoot>
@@ -84,4 +86,45 @@ describe('useCommandMenu', () => {
expect(result.current.isCommandMenuOpened).toBe(false);
});
it('should navigate command menu and reset navigation stack when resetNavigationStack is true', () => {
const { result } = renderHooks();
act(() => {
result.current.commandMenu.navigateCommandMenu({
page: CommandMenuPages.Root,
pageTitle: 'First Page',
pageIcon: IconDotsVertical,
resetNavigationStack: false,
});
});
expect(result.current.commandMenuPage).toBe(CommandMenuPages.Root);
expect(result.current.commandMenuPageInfo.title).toBe('First Page');
expect(result.current.commandMenuNavigationStack).toHaveLength(1);
act(() => {
result.current.commandMenu.navigateCommandMenu({
page: CommandMenuPages.SearchRecords,
pageTitle: 'Second Page',
pageIcon: IconDotsVertical,
resetNavigationStack: false,
});
});
expect(result.current.commandMenuNavigationStack).toHaveLength(2);
act(() => {
result.current.commandMenu.navigateCommandMenu({
page: CommandMenuPages.Root,
pageTitle: 'Reset Page',
pageIcon: IconDotsVertical,
resetNavigationStack: true,
});
});
expect(result.current.commandMenuPage).toBe(CommandMenuPages.Root);
expect(result.current.commandMenuPageInfo.title).toBe('Reset Page');
expect(result.current.commandMenuNavigationStack).toHaveLength(1);
});
});
@@ -0,0 +1,102 @@
import { type ActionConfig } from '@/action-menu/actions/types/ActionConfig';
import { ActionScope } from '@/action-menu/actions/types/ActionScope';
import { ActionType } from '@/action-menu/actions/types/ActionType';
import { renderHook } from '@testing-library/react';
import { IconPlus } from 'twenty-ui/display';
import { useFilterActionsWithCommandMenuSearch } from '../useFilterActionsWithCommandMenuSearch';
const MockComponent = <div>Mock Component</div>;
describe('useFilterActionsWithCommandMenuSearch', () => {
const mockActions: ActionConfig[] = [
{
key: 'action-1',
label: 'Create Record',
type: ActionType.Standard,
scope: ActionScope.Global,
position: 1,
Icon: IconPlus,
shouldBeRegistered: () => true,
component: MockComponent,
hotKeys: ['c', 'r'],
},
{
key: 'action-2',
label: 'Delete Record',
type: ActionType.Standard,
scope: ActionScope.RecordSelection,
position: 2,
Icon: IconPlus,
shouldBeRegistered: () => true,
component: MockComponent,
hotKeys: ['d', 'e', 'l'],
},
{
key: 'action-3',
label: 'Update Record',
type: ActionType.Standard,
scope: ActionScope.Object,
position: 3,
Icon: IconPlus,
shouldBeRegistered: () => true,
component: MockComponent,
},
];
it('should return all actions when search is empty', () => {
const { result } = renderHook(() =>
useFilterActionsWithCommandMenuSearch({ commandMenuSearch: '' }),
);
const filtered =
result.current.filterActionsWithCommandMenuSearch(mockActions);
expect(filtered).toEqual(mockActions);
});
it('should filter actions by label', () => {
const { result } = renderHook(() =>
useFilterActionsWithCommandMenuSearch({ commandMenuSearch: 'Create' }),
);
const filtered =
result.current.filterActionsWithCommandMenuSearch(mockActions);
expect(filtered).toHaveLength(1);
expect(filtered[0].key).toBe('action-1');
});
it('should filter actions by hotkeys', () => {
const { result } = renderHook(() =>
useFilterActionsWithCommandMenuSearch({ commandMenuSearch: 'del' }),
);
const filtered =
result.current.filterActionsWithCommandMenuSearch(mockActions);
expect(filtered).toHaveLength(1);
expect(filtered[0].key).toBe('action-2');
});
it('should return empty array when no actions match', () => {
const { result } = renderHook(() =>
useFilterActionsWithCommandMenuSearch({ commandMenuSearch: 'xyz' }),
);
const filtered =
result.current.filterActionsWithCommandMenuSearch(mockActions);
expect(filtered).toEqual([]);
});
it('should match actions by either label or hotkeys', () => {
const { result } = renderHook(() =>
useFilterActionsWithCommandMenuSearch({ commandMenuSearch: 'Record' }),
);
const filtered =
result.current.filterActionsWithCommandMenuSearch(mockActions);
expect(filtered).toHaveLength(3);
});
});
@@ -69,7 +69,7 @@ export const useCommandMenuHotKeys = () => {
keys: ['@'],
callback: () => {
if (isAiEnabled) {
openAskAIPage();
openAskAIPage({ resetNavigationStack: true });
}
},
containsModifier: false,
@@ -1,18 +1,33 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { IconSparkles } from 'twenty-ui/display';
import { v4 } from 'uuid';
export const useOpenAskAIPageInCommandMenu = () => {
const { navigateCommandMenu } = useCommandMenu();
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
const openAskAIPage = ({
pageTitle,
resetNavigationStack,
}: {
pageTitle?: string | null;
resetNavigationStack?: boolean;
} = {}) => {
const shouldReset =
resetNavigationStack !== undefined
? resetNavigationStack
: isCommandMenuOpened;
const openAskAIPage = (pageTitle?: string | null) => {
navigateCommandMenu({
page: CommandMenuPages.AskAI,
pageTitle: pageTitle ?? t`Ask AI`,
pageIcon: IconSparkles,
pageId: v4(),
resetNavigationStack: shouldReset,
});
};
@@ -1,11 +1,14 @@
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { v4 } from 'uuid';
import { IconSearch } from 'twenty-ui/display';
export const useOpenRecordsSearchPageInCommandMenu = () => {
const { navigateCommandMenu } = useCommandMenu();
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
const openRecordsSearchPage = () => {
navigateCommandMenu({
@@ -13,6 +16,7 @@ export const useOpenRecordsSearchPageInCommandMenu = () => {
pageTitle: t`Search`,
pageIcon: IconSearch,
pageId: v4(),
resetNavigationStack: isCommandMenuOpened,
});
};
@@ -1,12 +1,15 @@
import { useNavigateCommandMenu } from '@/command-menu/hooks/useNavigateCommandMenu';
import { commandMenuWorkflowIdComponentState } from '@/command-menu/pages/workflow/states/commandMenuWorkflowIdComponentState';
import { commandMenuWorkflowRunIdComponentState } from '@/command-menu/pages/workflow/states/commandMenuWorkflowRunIdComponentState';
import { commandMenuWorkflowStepIdComponentState } from '@/command-menu/pages/workflow/states/commandMenuWorkflowStepIdComponentState';
import { commandMenuWorkflowVersionIdComponentState } from '@/command-menu/pages/workflow/states/commandMenuWorkflowVersionIdComponentState';
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { type WorkflowRunStepStatus } from '@/workflow/types/Workflow';
import { useSetInitialWorkflowRunRightDrawerTab } from '@/workflow/workflow-diagram/hooks/useSetInitialWorkflowRunRightDrawerTab';
import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState';
import { t } from '@lingui/core/macro';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import {
IconBolt,
type IconComponent,
@@ -67,7 +70,12 @@ export const useWorkflowCommandMenu = () => {
const openWorkflowEditStepInCommandMenu = useRecoilCallback(
({ set }) => {
return (workflowId: string, title: string, icon: IconComponent) => {
return (
workflowId: string,
title: string,
icon: IconComponent,
stepId?: string,
) => {
const pageId = v4();
set(
@@ -77,6 +85,22 @@ export const useWorkflowCommandMenu = () => {
workflowId,
);
if (isDefined(stepId)) {
set(
commandMenuWorkflowStepIdComponentState.atomFamily({
instanceId: pageId,
}),
stepId,
);
set(
workflowSelectedNodeComponentState.atomFamily({
instanceId: workflowId,
}),
stepId,
);
}
navigateCommandMenu({
page: CommandMenuPages.WorkflowStepEdit,
pageTitle: title,
@@ -118,11 +142,13 @@ export const useWorkflowCommandMenu = () => {
workflowVersionId,
title,
icon,
stepId,
}: {
workflowId: string;
workflowVersionId: string;
title: string;
icon: IconComponent;
stepId?: string;
}) => {
const pageId = v4();
@@ -139,6 +165,22 @@ export const useWorkflowCommandMenu = () => {
workflowVersionId,
);
if (isDefined(stepId)) {
set(
commandMenuWorkflowStepIdComponentState.atomFamily({
instanceId: pageId,
}),
stepId,
);
set(
workflowSelectedNodeComponentState.atomFamily({
instanceId: workflowVersionId,
}),
stepId,
);
}
navigateCommandMenu({
page: CommandMenuPages.WorkflowStepView,
pageTitle: title,
@@ -181,6 +223,19 @@ export const useWorkflowCommandMenu = () => {
}),
workflowRunId,
);
set(
commandMenuWorkflowStepIdComponentState.atomFamily({
instanceId: pageId,
}),
workflowSelectedNode,
);
set(
workflowSelectedNodeComponentState.atomFamily({
instanceId: workflowRunId,
}),
workflowSelectedNode,
);
navigateCommandMenu({
page: CommandMenuPages.WorkflowRunStepView,
@@ -172,6 +172,7 @@ export const CommandMenuMessageThreadPage = () => {
{canReply && !messageChannelLoading && (
<StyledButtonContainer isMobile={isMobile}>
<Button
size="small"
onClick={handleReplyClick}
title={t`Reply`}
Icon={IconArrowBackUp}
@@ -1,19 +1,13 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { ChartFiltersSettings } from '@/command-menu/pages/page-layout/components/ChartFiltersSettings';
import { GRAPH_TYPE_INFORMATION } from '@/command-menu/pages/page-layout/constants/GraphTypeInformation';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { isChartWidget } from '@/command-menu/pages/page-layout/utils/isChartWidget';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
export const CommandMenuPageLayoutGraphFilter = () => {
const theme = useTheme();
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const draftPageLayout = useRecoilComponentValue(
@@ -52,18 +46,8 @@ export const CommandMenuPageLayoutGraphFilter = () => {
return null;
}
const currentGraphType = widgetInEditMode.configuration.graphType;
const graphTypeLabel = t(GRAPH_TYPE_INFORMATION[currentGraphType].label);
return (
<>
<SidePanelHeader
Icon={GRAPH_TYPE_INFORMATION[currentGraphType].icon}
iconColor={theme.font.color.tertiary}
initialTitle={widgetInEditMode.title}
headerType={t`${graphTypeLabel} Chart`}
disabled
/>
<ChartFiltersSettings
widget={widgetInEditMode}
objectMetadataItem={objectMetadataItem}
@@ -1,15 +1,9 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { ChartSettings } from '@/command-menu/pages/page-layout/components/ChartSettings';
import { GRAPH_TYPE_INFORMATION } from '@/command-menu/pages/page-layout/constants/GraphTypeInformation';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pageLayoutEditingWidgetIdComponentState';
import { GraphWidgetComponentInstanceContext } from '@/page-layout/widgets/graph/states/contexts/GraphWidgetComponentInstanceContext';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
export const CommandMenuPageLayoutGraphTypeSelect = () => {
@@ -25,8 +19,6 @@ export const CommandMenuPageLayoutGraphTypeSelect = () => {
pageLayoutId,
);
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
if (!isDefined(pageLayoutEditingWidgetId)) {
throw new Error('Widget ID must be present while editing the widget');
}
@@ -41,8 +33,6 @@ export const CommandMenuPageLayoutGraphTypeSelect = () => {
);
}
const theme = useTheme();
if (
!isDefined(widgetInEditMode.configuration) ||
!('graphType' in widgetInEditMode.configuration)
@@ -50,26 +40,10 @@ export const CommandMenuPageLayoutGraphTypeSelect = () => {
return null;
}
const currentGraphType = widgetInEditMode.configuration.graphType;
const graphTypeLabel = t(GRAPH_TYPE_INFORMATION[currentGraphType].label);
return (
<GraphWidgetComponentInstanceContext.Provider
value={{ instanceId: widgetInEditMode.id }}
>
<SidePanelHeader
Icon={GRAPH_TYPE_INFORMATION[currentGraphType].icon}
iconColor={theme.font.color.tertiary}
initialTitle={widgetInEditMode.title}
headerType={t`${graphTypeLabel} Chart`}
onTitleChange={(newTitle) => {
if (isNonEmptyString(newTitle)) {
updatePageLayoutWidget(widgetInEditMode.id, {
title: newTitle,
});
}
}}
/>
<ChartSettings widget={widgetInEditMode} />
</GraphWidgetComponentInstanceContext.Provider>
);
@@ -1,15 +1,12 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { useUpdatePageLayoutWidget } from '@/page-layout/hooks/useUpdatePageLayoutWidget';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { isNonEmptyString, isString } from '@sniptt/guards';
import { useState } from 'react';
import { isDefined, isValidUrl } from 'twenty-shared/utils';
import { IconFrame } from 'twenty-ui/display';
const StyledContainer = styled.div`
display: flex;
@@ -21,8 +18,6 @@ const StyledContainer = styled.div`
export const CommandMenuPageLayoutIframeSettings = () => {
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const theme = useTheme();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const { updatePageLayoutWidget } = useUpdatePageLayoutWidget(pageLayoutId);
@@ -74,21 +69,6 @@ export const CommandMenuPageLayoutIframeSettings = () => {
return (
<>
<SidePanelHeader
Icon={IconFrame}
iconColor={theme.font.color.tertiary}
initialTitle={widgetInEditMode.title}
headerType={t`iFrame Widget`}
onTitleChange={(newTitle) => {
if (!isNonEmptyString(newTitle)) {
return;
}
updatePageLayoutWidget(widgetInEditMode.id, {
title: newTitle,
});
}}
/>
<StyledContainer>
<FormTextFieldInput
label={t`URL to Embed`}
@@ -1,26 +1,21 @@
import { CommandGroup } from '@/command-menu/components/CommandGroup';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { CommandMenuList } from '@/command-menu/components/CommandMenuList';
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { TAB_SETTINGS_SELECTABLE_ITEM_IDS } from '@/command-menu/pages/page-layout/constants/settings/TabSettingsSelectableItemIds';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useDeletePageLayoutTab } from '@/page-layout/hooks/useDeletePageLayoutTab';
import { useDuplicatePageLayoutTab } from '@/page-layout/hooks/useDuplicatePageLayoutTab';
import { useMovePageLayoutTab } from '@/page-layout/hooks/useMovePageLayoutTab';
import { useUpdatePageLayoutTab } from '@/page-layout/hooks/useUpdatePageLayoutTab';
import { pageLayoutDraftComponentState } from '@/page-layout/states/pageLayoutDraftComponentState';
import { pageLayoutTabSettingsOpenTabIdComponentState } from '@/page-layout/states/pageLayoutTabSettingsOpenTabIdComponentState';
import { sortTabsByPosition } from '@/page-layout/utils/sortTabsByPosition';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
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 { t } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
import {
IconAppWindow,
IconChevronLeft,
IconChevronRight,
IconCopyPlus,
@@ -28,7 +23,6 @@ import {
} from 'twenty-ui/display';
export const CommandMenuPageLayoutTabSettings = () => {
const theme = useTheme();
const { closeCommandMenu } = useCommandMenu();
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
@@ -44,7 +38,6 @@ export const CommandMenuPageLayoutTabSettings = () => {
const { moveLeft, moveRight } = useMovePageLayoutTab(pageLayoutId);
const { deleteTab } = useDeletePageLayoutTab(pageLayoutId);
const { updatePageLayoutTab } = useUpdatePageLayoutTab(pageLayoutId);
const { duplicateTab } = useDuplicatePageLayoutTab(pageLayoutId);
if (!isDefined(openTabId)) {
@@ -69,17 +62,6 @@ export const CommandMenuPageLayoutTabSettings = () => {
return (
<>
<SidePanelHeader
Icon={IconAppWindow}
iconColor={theme.font.color.tertiary}
initialTitle={tab.title}
headerType={t`Tab`}
onTitleChange={(newTitle) => {
if (isDefined(newTitle) && isNonEmptyString(newTitle)) {
updatePageLayoutTab(tab.id, { title: newTitle });
}
}}
/>
<CommandMenuList
commandGroups={[]}
selectableItemIds={Object.values(TAB_SETTINGS_SELECTABLE_ITEM_IDS)}
@@ -11,6 +11,7 @@ type NavigatePageLayoutCommandMenuProps = {
pageTitle?: string;
pageIcon?: IconComponent;
focusTitleInput?: boolean;
resetNavigationStack?: boolean;
};
export const useNavigatePageLayoutCommandMenu = () => {
@@ -22,6 +23,7 @@ export const useNavigatePageLayoutCommandMenu = () => {
pageTitle,
pageIcon,
focusTitleInput = false,
resetNavigationStack = false,
}: NavigatePageLayoutCommandMenuProps) => {
navigateCommandMenu({
page: commandMenuPage,
@@ -32,6 +34,7 @@ export const useNavigatePageLayoutCommandMenu = () => {
? pageIcon
: getPageLayoutIcon(commandMenuPage),
focusTitleInput,
resetNavigationStack,
});
};
}, [navigateCommandMenu]);
@@ -0,0 +1,10 @@
import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const commandMenuWorkflowStepIdComponentState = createComponentState<
string | undefined
>({
key: 'command-menu/workflow-step-id',
defaultValue: undefined,
componentInstanceContext: CommandMenuPageComponentInstanceContext,
});
@@ -66,6 +66,7 @@ export const CommandMenuWorkflowCreateStepContent = () => {
workflowVisualizerWorkflowId,
createdStep.name,
getIcon(getActionIcon(createdStep.type as WorkflowActionType)),
createdStep.id,
);
};
@@ -66,6 +66,7 @@ export const CommandMenuWorkflowEditStepTypeContent = () => {
workflowVisualizerWorkflowId,
updatedStep.name,
getIcon(getActionIcon(updatedStep.type as WorkflowActionType)),
updatedStep.id,
);
};
@@ -66,6 +66,7 @@ export const CommandMenuWorkflowSelectTriggerTypeContent = () => {
workflowId,
defaultLabel,
getIcon(icon),
TRIGGER_STEP_ID,
);
};
};
@@ -49,7 +49,7 @@ export const MainNavigationDrawerFixedItems = () => {
<NavigationDrawerItem
label={t`Ask AI`}
Icon={IconSparkles}
onClick={() => openAskAIPage()}
onClick={() => openAskAIPage({ resetNavigationStack: true })}
keyboard={['@']}
mouseUpNavigation={true}
/>
@@ -0,0 +1,151 @@
import { CommandMenuRouter } from '@/command-menu/components/CommandMenuRouter';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState';
import { ModalContainerContext } from '@/ui/layout/modal/contexts/ModalContainerContext';
import { PageBody } from '@/ui/layout/page/components/PageBody';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { type ReactNode, useCallback, useState } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useIsMobile } from 'twenty-ui/utilities';
type CommandMenuPageLayoutProps = {
children: ReactNode;
isSidePanelOpen?: boolean;
};
const DEFAULT_SIDE_PANEL_WIDTH = 400;
const StyledLayout = styled.div`
display: flex;
flex: 1;
min-height: 0;
padding-bottom: ${({ theme }) => theme.spacing(3)};
padding-right: ${({ theme }) => theme.spacing(3)};
`;
const StyledPageBody = styled(PageBody)`
flex: 1;
min-width: 0;
padding-bottom: 0;
padding-right: 0;
`;
const StyledSidePanelWrapper = styled(motion.div)`
flex-shrink: 0;
min-width: 0;
overflow: hidden;
`;
const StyledSidePanel = styled(motion.aside)`
background: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
position: relative;
width: ${DEFAULT_SIDE_PANEL_WIDTH}px;
box-sizing: border-box;
`;
const StyledModalContainer = styled.div`
height: 100%;
left: 0;
pointer-events: none;
position: absolute;
top: 0;
width: 100%;
z-index: 1;
`;
export const CommandMenuPageLayout = ({
children,
isSidePanelOpen,
}: CommandMenuPageLayoutProps) => {
const theme = useTheme();
const isMobile = useIsMobile();
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
const [modalContainer, setModalContainer] = useState<HTMLDivElement | null>(
null,
);
const setTableWidthResizeIsActive = useSetRecoilState(
tableWidthResizeIsActiveState,
);
const resolvedIsSidePanelOpen =
isSidePanelOpen ?? isCommandMenuOpened ?? false;
const [shouldRenderContent, setShouldRenderContent] = useState(
resolvedIsSidePanelOpen,
);
const shouldShowContent = resolvedIsSidePanelOpen || shouldRenderContent;
const handleAnimationComplete = () => {
if (!resolvedIsSidePanelOpen) {
setShouldRenderContent(false);
}
setTableWidthResizeIsActive(true);
};
const handleAnimationStart = () => {
if (resolvedIsSidePanelOpen && !shouldRenderContent) {
setShouldRenderContent(true);
}
setTableWidthResizeIsActive(false);
};
const handleModalContainerRef = useCallback(
(element: HTMLDivElement | null) => {
setModalContainer(element);
},
[],
);
useCommandMenuHotKeys();
if (isMobile) {
return <>{children}</>;
}
return (
<StyledLayout>
<StyledPageBody>{children}</StyledPageBody>
<StyledSidePanelWrapper
initial={false}
animate={{
width: resolvedIsSidePanelOpen ? DEFAULT_SIDE_PANEL_WIDTH : 0,
marginLeft: resolvedIsSidePanelOpen ? theme.spacing(2) : 0,
}}
transition={{
duration: theme.animation.duration.normal,
}}
onAnimationStart={handleAnimationStart}
onAnimationComplete={handleAnimationComplete}
>
<StyledSidePanel
initial={false}
animate={{
x: resolvedIsSidePanelOpen ? 0 : DEFAULT_SIDE_PANEL_WIDTH,
}}
transition={{
duration: theme.animation.duration.normal,
}}
>
<StyledModalContainer ref={handleModalContainerRef} />
<ModalContainerContext.Provider value={{ container: modalContainer }}>
{shouldShowContent && <CommandMenuRouter />}
</ModalContainerContext.Provider>
</StyledSidePanel>
</StyledSidePanelWrapper>
</StyledLayout>
);
};
@@ -0,0 +1,144 @@
import { CommandMenuRouter } from '@/command-menu/components/CommandMenuRouter';
import { COMMAND_MENU_SIDE_PANEL_WIDTH } from '@/command-menu/constants/CommandMenuSidePanelWidth';
import { useCommandMenuHotKeys } from '@/command-menu/hooks/useCommandMenuHotKeys';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState';
import { ModalContainerContext } from '@/ui/layout/modal/contexts/ModalContainerContext';
import { PageBody } from '@/ui/layout/page/components/PageBody';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { motion } from 'framer-motion';
import { type ReactNode, useCallback, useState } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { useIsMobile } from 'twenty-ui/utilities';
type CommandMenuPageLayoutProps = {
children: ReactNode;
};
const StyledLayout = styled.div`
display: flex;
flex: 1;
min-height: 0;
padding-bottom: ${({ theme }) => theme.spacing(3)};
padding-right: ${({ theme }) => theme.spacing(3)};
`;
const StyledPageBody = styled(PageBody)`
flex: 1;
min-width: 0;
padding-bottom: 0;
padding-right: 0;
`;
const StyledSidePanelWrapper = styled(motion.div)`
flex-shrink: 0;
min-width: 0;
overflow: hidden;
`;
const StyledSidePanel = styled(motion.aside)`
background: ${({ theme }) => theme.background.primary};
border: 1px solid ${({ theme }) => theme.border.color.medium};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
position: relative;
width: ${COMMAND_MENU_SIDE_PANEL_WIDTH}px;
box-sizing: border-box;
`;
const StyledModalContainer = styled.div`
height: 100%;
left: 0;
pointer-events: none;
position: absolute;
top: 0;
width: 100%;
z-index: 1;
`;
export const CommandMenuPageLayout = ({
children,
}: CommandMenuPageLayoutProps) => {
const theme = useTheme();
const isMobile = useIsMobile();
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
const [modalContainer, setModalContainer] = useState<HTMLDivElement | null>(
null,
);
const setTableWidthResizeIsActive = useSetRecoilState(
tableWidthResizeIsActiveState,
);
const [shouldRenderContent, setShouldRenderContent] =
useState(isCommandMenuOpened);
const shouldShowContent = isCommandMenuOpened || shouldRenderContent;
const handleAnimationComplete = () => {
if (!isCommandMenuOpened) {
setShouldRenderContent(false);
}
setTableWidthResizeIsActive(true);
};
const handleAnimationStart = () => {
if (isCommandMenuOpened && !shouldRenderContent) {
setShouldRenderContent(true);
}
setTableWidthResizeIsActive(false);
};
const handleModalContainerRef = useCallback(
(element: HTMLDivElement | null) => {
setModalContainer(element);
},
[],
);
useCommandMenuHotKeys();
if (isMobile) {
return <>{children}</>;
}
return (
<StyledLayout>
<StyledPageBody>{children}</StyledPageBody>
<StyledSidePanelWrapper
initial={false}
animate={{
width: isCommandMenuOpened ? COMMAND_MENU_SIDE_PANEL_WIDTH : 0,
marginLeft: isCommandMenuOpened ? theme.spacing(2) : 0,
}}
transition={{
duration: theme.animation.duration.normal,
}}
onAnimationStart={handleAnimationStart}
onAnimationComplete={handleAnimationComplete}
>
<StyledSidePanel
initial={false}
animate={{
x: isCommandMenuOpened ? 0 : COMMAND_MENU_SIDE_PANEL_WIDTH,
}}
transition={{
duration: theme.animation.duration.normal,
}}
>
<StyledModalContainer ref={handleModalContainerRef} />
<ModalContainerContext.Provider value={{ container: modalContainer }}>
{shouldShowContent && <CommandMenuRouter />}
</ModalContainerContext.Provider>
</StyledSidePanel>
</StyledSidePanelWrapper>
</StyledLayout>
);
};
@@ -3,6 +3,7 @@ import { RecordIndexContextProvider } from '@/object-record/record-index/context
import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
import { getActionMenuIdFromRecordIndexId } from '@/action-menu/utils/getActionMenuIdFromRecordIndexId';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { CommandMenuPageLayout } from '@/object-record/components/CommandMenuPageLayout';
import { RecordComponentInstanceContextsWrapper } from '@/object-record/components/RecordComponentInstanceContextsWrapper';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { lastShowPageRecordIdState } from '@/object-record/record-field/ui/states/lastShowPageRecordId';
@@ -13,7 +14,6 @@ import { RecordIndexPageHeader } from '@/object-record/record-index/components/R
import { useHandleIndexIdentifierClick } from '@/object-record/record-index/hooks/useHandleIndexIdentifierClick';
import { useRecordIndexFieldMetadataDerivedStates } from '@/object-record/record-index/hooks/useRecordIndexFieldMetadataDerivedStates';
import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore';
import { PageBody } from '@/ui/layout/page/components/PageBody';
import { RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS } from '@/ui/utilities/drag-select/constants/RecordIndecDragSelectBoundaryClass';
import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle';
import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext';
@@ -98,14 +98,14 @@ export const RecordIndexContainerGater = () => {
>
<PageTitle title={objectMetadataItem.labelPlural} />
<RecordIndexPageHeader />
<PageBody>
<CommandMenuPageLayout>
<StyledIndexContainer
className={RECORD_INDEX_DRAG_SELECT_BOUNDARY_CLASS}
>
<RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect />
<RecordIndexContainer />
</StyledIndexContainer>
</PageBody>
</CommandMenuPageLayout>
</ActionMenuComponentInstanceContext.Provider>
</RecordComponentInstanceContextsWrapper>
<RecordIndexLoadBaseOnContextStoreEffect />
@@ -1,8 +1,10 @@
import { recordTableWidthComponentState } from '@/object-record/record-table/states/recordTableWidthComponentState';
import { tableWidthResizeIsActiveState } from '@/object-record/record-table/states/tableWidthResizeIsActivedState';
import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const RecordTableWidthEffect = () => {
@@ -10,33 +12,38 @@ export const RecordTableWidthEffect = () => {
recordTableWidthComponentState,
);
const tableWidthResizeIsActive = useRecoilValue(
tableWidthResizeIsActiveState,
);
const { scrollWrapperHTMLElement } = useScrollWrapperHTMLElement();
useEffect(() => {
const tableWidth = scrollWrapperHTMLElement?.clientWidth ?? 0;
if (tableWidth > 0) {
setRecordTableWidth(tableWidth);
}
const tableResizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === scrollWrapperHTMLElement) {
const newWidth = scrollWrapperHTMLElement.clientWidth;
setRecordTableWidth(newWidth);
}
if (tableWidthResizeIsActive) {
if (tableWidth > 0) {
setRecordTableWidth(tableWidth);
}
});
if (isDefined(scrollWrapperHTMLElement)) {
tableResizeObserver.observe(scrollWrapperHTMLElement);
const tableResizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === scrollWrapperHTMLElement) {
const newWidth = scrollWrapperHTMLElement.clientWidth;
setRecordTableWidth(newWidth);
}
}
});
if (isDefined(scrollWrapperHTMLElement)) {
tableResizeObserver.observe(scrollWrapperHTMLElement);
}
return () => {
tableResizeObserver.disconnect();
};
}
return () => {
tableResizeObserver.disconnect();
};
}, [setRecordTableWidth, scrollWrapperHTMLElement]);
}, [setRecordTableWidth, scrollWrapperHTMLElement, tableWidthResizeIsActive]);
return null;
};
@@ -0,0 +1,6 @@
import { createState } from 'twenty-ui/utilities';
export const tableWidthResizeIsActiveState = createState({
key: 'tableWidthResizeIsActiveState',
defaultValue: true,
});
@@ -26,7 +26,7 @@ import { useRecoilCallback } from 'recoil';
type RecordTitleCellProps = {
loading?: boolean;
sizeVariant?: 'xs' | 'md';
sizeVariant?: 'xs' | 'sm' | 'md';
containerType: RecordTitleCellContainerType;
};
@@ -8,7 +8,7 @@ import { RecordTitleFullNameFieldInput } from '@/object-record/record-title-cell
type RecordTitleCellFieldInputProps = {
instanceId: string;
sizeVariant?: 'xs' | 'md';
sizeVariant?: 'xs' | 'sm' | 'md';
};
export const RecordTitleCellFieldInput = ({
@@ -9,7 +9,7 @@ import { turnIntoUndefinedIfWhitespacesOnly } from '~/utils/string/turnIntoUndef
type RecordTitleCellTextFieldInputProps = {
instanceId: string;
sizeVariant?: 'xs' | 'md';
sizeVariant?: 'xs' | 'sm' | 'md';
};
export const RecordTitleCellTextFieldInput = ({
@@ -38,7 +38,7 @@ type RecordTitleDoubleTextInputProps = {
) => void;
onChange?: (newDoubleTextValue: FieldDoubleText) => void;
onPaste?: (newDoubleTextValue: FieldDoubleText) => void;
sizeVariant?: 'xs' | 'md';
sizeVariant?: 'xs' | 'sm' | 'md';
};
export const RecordTitleDoubleTextInput = ({
@@ -9,7 +9,7 @@ import { useContext } from 'react';
import { RecordTitleDoubleTextInput } from './RecordTitleDoubleTextInput';
type RecordTitleFullNameFieldInputProps = {
sizeVariant?: 'xs' | 'md';
sizeVariant?: 'xs' | 'sm' | 'md';
};
export const RecordTitleFullNameFieldInput = ({
@@ -37,6 +37,7 @@ export const useEditPageLayoutWidget = (pageLayoutIdFromProps?: string) => {
navigatePageLayoutCommandMenu({
commandMenuPage: CommandMenuPages.PageLayoutIframeSettings,
pageTitle: t`Edit iFrame`,
resetNavigationStack: true,
});
}
@@ -44,6 +45,7 @@ export const useEditPageLayoutWidget = (pageLayoutIdFromProps?: string) => {
navigatePageLayoutCommandMenu({
commandMenuPage: CommandMenuPages.PageLayoutGraphTypeSelect,
pageTitle: t`Edit Graph`,
resetNavigationStack: true,
});
}
},
@@ -27,7 +27,7 @@ const StyledEditor = styled.div`
width: 100%;
& .editor {
background: ${({ theme }) => theme.background.primary};
background: transparent;
font-size: 13px;
color: ${({ theme }) => theme.font.color.primary};
min-height: 400px;
@@ -1,6 +1,7 @@
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { ModalHotkeysAndClickOutsideEffect } from '@/ui/layout/modal/components/ModalHotkeysAndClickOutsideEffect';
import { ModalComponentInstanceContext } from '@/ui/layout/modal/contexts/ModalComponentInstanceContext';
import { useModalContainer } from '@/ui/layout/modal/contexts/ModalContainerContext';
import { isModalOpenedComponentState } from '@/ui/layout/modal/states/isModalOpenedComponentState';
import { MODAL_BACKDROP_CLICK_OUTSIDE_ID } from '@/ui/layout/modal/constants/ModalBackdropClickOutsideId';
@@ -13,6 +14,7 @@ import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { AnimatePresence, motion } from 'framer-motion';
import React, { useRef } from 'react';
import { createPortal } from 'react-dom';
const StyledModalDiv = styled(motion.div)<{
size?: ModalSize;
padding?: ModalPadding;
@@ -122,19 +124,23 @@ const StyledFooter = styled.div`
const StyledBackDrop = styled(motion.div)<{
modalVariant: ModalVariants;
isInContainer?: boolean;
}>`
align-items: center;
background: ${({ theme, modalVariant }) =>
modalVariant === 'primary' || modalVariant === 'transparent'
? theme.background.overlayPrimary
: modalVariant === 'secondary'
? theme.background.overlaySecondary
: theme.background.overlayTertiary};
background: ${({ theme, modalVariant, isInContainer }) =>
isInContainer
? theme.background.overlayTertiary
: modalVariant === 'primary' || modalVariant === 'transparent'
? theme.background.overlayPrimary
: modalVariant === 'secondary'
? theme.background.overlaySecondary
: theme.background.overlayTertiary};
display: flex;
height: 100%;
justify-content: center;
left: 0;
position: fixed;
pointer-events: auto;
position: ${({ isInContainer }) => (isInContainer ? 'absolute' : 'fixed')};
top: 0;
width: 100%;
z-index: ${RootStackingContextZIndices.RootModalBackDrop};
@@ -220,6 +226,8 @@ export const Modal = ({
}: ModalProps) => {
const isMobile = useIsMobile();
const modalRef = useRef<HTMLDivElement>(null);
const { container } = useModalContainer();
const isInContainer = container !== null;
const theme = useTheme();
@@ -239,7 +247,7 @@ export const Modal = ({
if (shouldCloseModalOnClickOutsideOrEscape) closeModal(modalId);
};
return (
const modalContent = (
<AnimatePresence mode="wait">
{isModalOpened && (
<ModalComponentInstanceContext.Provider
@@ -264,6 +272,7 @@ export const Modal = ({
data-click-outside-id={MODAL_BACKDROP_CLICK_OUTSIDE_ID}
onMouseDown={stopEventPropagation}
modalVariant={modalVariant}
isInContainer={isInContainer}
>
<StyledModalDiv
ref={modalRef}
@@ -290,6 +299,12 @@ export const Modal = ({
)}
</AnimatePresence>
);
if (container !== null) {
return createPortal(modalContent, container);
}
return modalContent;
};
Modal.Header = ModalHeader;
@@ -0,0 +1,13 @@
import { createContext, useContext } from 'react';
export type ModalContainerContextValue = {
container: HTMLElement | null;
};
export const ModalContainerContext = createContext<ModalContainerContextValue>({
container: null,
});
export const useModalContainer = () => {
return useContext(ModalContainerContext);
};
@@ -1,5 +1,7 @@
import { AuthModal } from '@/auth/components/AuthModal';
import { CommandMenuOpenContainer } from '@/command-menu/components/CommandMenuOpenContainer';
import { CommandMenuRouter } from '@/command-menu/components/CommandMenuRouter';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary';
import { AppFullScreenErrorFallback } from '@/error-handler/components/AppFullScreenErrorFallback';
import { AppPageErrorFallback } from '@/error-handler/components/AppPageErrorFallback';
@@ -19,6 +21,7 @@ import { Global, css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { AnimatePresence, LayoutGroup, motion } from 'framer-motion';
import { Outlet } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { useScreenSize } from 'twenty-ui/utilities';
const StyledLayout = styled.div`
@@ -64,6 +67,7 @@ export const DefaultLayout = () => {
const windowsWidth = useScreenSize().width;
const showAuthModal = useShowAuthModal();
const useShowFullScreen = useShowFullscreen();
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
return (
<>
@@ -92,12 +96,7 @@ export const DefaultLayout = () => {
duration: theme.animation.duration.normal,
}}
>
{!showAuthModal && (
<>
<CommandMenuRouter />
<KeyboardShortcutMenu />
</>
)}
{!showAuthModal && <KeyboardShortcutMenu />}
{showAuthModal ? (
<StyledAppNavigationDrawerMock />
) : useShowFullScreen ? null : (
@@ -125,6 +124,15 @@ export const DefaultLayout = () => {
)}
</StyledPageContainer>
{isMobile && !showAuthModal && <MobileNavigationBar />}
{isMobile && (
<AnimatePresence>
{isCommandMenuOpened && (
<CommandMenuOpenContainer>
<CommandMenuRouter />
</CommandMenuOpenContainer>
)}
</AnimatePresence>
)}
</AppErrorBoundary>
</StyledLayout>
</>
@@ -6,6 +6,7 @@ import { PagePanel } from './PagePanel';
type PageBodyProps = {
children: ReactNode;
className?: string;
};
const StyledMainContainer = styled.div`
@@ -38,8 +39,8 @@ const StyledLeftContainer = styled.div<LeftContainerProps>`
width: 100%;
`;
export const PageBody = ({ children }: PageBodyProps) => (
<StyledMainContainer>
export const PageBody = ({ children, className }: PageBodyProps) => (
<StyledMainContainer className={className}>
<StyledLeftContainer>
<PagePanel>{children}</PagePanel>
</StyledLeftContainer>
@@ -33,12 +33,22 @@ export const SelectableListItem = ({
const listItemRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isSelectedItemId) {
listItemRef.current?.scrollIntoView({
behavior: 'auto',
block: 'start',
});
if (!isSelectedItemId || !listItemRef.current) {
return;
}
const scrollContainer = listItemRef.current.closest(
'[id^="scroll-wrapper-"]',
) as HTMLElement | null;
if (isDefined(scrollContainer) && scrollContainer.scrollTop === 0) {
return;
}
listItemRef.current.scrollIntoView({
behavior: 'auto',
block: 'start',
});
}, [isSelectedItemId]);
return (
@@ -20,6 +20,7 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho
import { css } from '@emotion/react';
import styled from '@emotion/styled';
import React from 'react';
import { isDefined } from 'twenty-shared/utils';
const StyledShowPageRightContainer = styled.div`
display: flex;
@@ -44,12 +45,23 @@ const StyledTabList = styled(TabList)`
padding-left: ${({ theme }) => theme.spacing(2)};
`;
const StyledContentContainer = styled.div<{ isInRightDrawer: boolean }>`
const StyledContentContainer = styled.div<{
isInRightDrawer: boolean;
}>`
background: ${({ theme, isInRightDrawer }) =>
isInRightDrawer ? theme.background.secondary : theme.background.primary};
border: ${({ theme, isInRightDrawer }) =>
isInRightDrawer ? `1px solid ${theme.border.color.light}` : 'none'};
border-radius: ${({ theme, isInRightDrawer }) =>
isInRightDrawer ? theme.border.radius.md : '0'};
flex: 1;
margin: ${({ theme, isInRightDrawer }) =>
isInRightDrawer ? theme.spacing(3) : '0'};
overflow-y: auto;
background: ${({ theme }) => theme.background.primary};
padding-bottom: ${({ theme, isInRightDrawer }) =>
isInRightDrawer ? theme.spacing(16) : 0};
.scroll-wrapper-y-enabled {
height: auto;
}
`;
type ShowPageSubContainerProps = {
@@ -109,6 +121,8 @@ export const ShowPageSubContainer = ({
const visibleTabs = tabs.filter((tab) => !tab.hide);
const isInCommandMenu = isDefined(commandMenuPageComponentInstance);
const displaySummaryAndFields =
layout && !layout.hideSummaryAndFields && !isMobile && !isInRightDrawer;
@@ -132,7 +146,7 @@ export const ShowPageSubContainer = ({
componentInstanceId={tabListComponentId}
/>
</StyledTabListContainer>
{(isMobile || isInRightDrawer) && summaryCard}
{(isMobile || isInRightDrawer) && !isInCommandMenu && summaryCard}
<StyledContentContainer isInRightDrawer={isInRightDrawer}>
{renderActiveTabContent()}
</StyledContentContainer>
@@ -0,0 +1,39 @@
import { isDefined } from 'twenty-shared/utils';
type StepDefinition = {
type: 'trigger' | 'action';
definition: any;
};
export const getAgentIdFromStep = (
stepDefinition: StepDefinition | undefined,
): string | undefined => {
if (!isDefined(stepDefinition)) {
return undefined;
}
if (stepDefinition.type === 'trigger') {
return undefined;
}
const definition = stepDefinition.definition;
if (!isDefined(definition)) {
return undefined;
}
if (definition.type !== 'AI_AGENT') {
return undefined;
}
if (
!('settings' in definition) ||
!isDefined(definition.settings) ||
!('input' in definition.settings) ||
!isDefined(definition.settings.input) ||
!('agentId' in definition.settings.input)
) {
return undefined;
}
return definition.settings.input.agentId;
};
@@ -1,12 +1,11 @@
import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext';
import { type CommandMenuAnimationVariant } from '@/command-menu/types/CommandMenuAnimationVariant';
import { COMMAND_MENU_SIDE_PANEL_WIDTH } from '@/command-menu/constants/CommandMenuSidePanelWidth';
import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState';
import { useListenToSidePanelClosing } from '@/ui/layout/right-drawer/hooks/useListenToSidePanelClosing';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState';
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { WorkflowDiagramRightClickCommandMenu } from '@/workflow/workflow-diagram/components/WorkflowDiagramRightClickCommandMenu';
import { useRightDrawerState } from '@/workflow/workflow-diagram/hooks/useRightDrawerState';
import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState';
import { workflowDiagramPanOnDragComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramPanOnDragComponentState';
import { workflowDiagramWaitingNodesDimensionsComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramWaitingNodesDimensionsComponentState';
@@ -46,16 +45,14 @@ import {
import '@xyflow/react/dist/style.css';
import React, {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useRecoilCallback } from 'recoil';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { Tag, type TagColor } from 'twenty-ui/components';
import { THEME_COMMON } from 'twenty-ui/theme';
const StyledResetReactflowStyles = styled.div`
height: 100%;
@@ -197,8 +194,7 @@ export const WorkflowDiagramCanvasBase = ({
return { nodes: [], edges: [] };
}, [workflowDiagram]);
const { rightDrawerState } = useRightDrawerState();
const { isInRightDrawer } = useContext(ActionMenuContext);
const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState);
const handleEdgesChange = (
edgeChanges: Array<EdgeChange<WorkflowDiagramEdge>>,
@@ -237,16 +233,12 @@ export const WorkflowDiagramCanvasBase = ({
const setFlowViewport = useRecoilCallback(
() =>
({
rightDrawerState,
noAnimation,
workflowDiagramFlowInitialized,
isInRightDrawer,
isCommandMenuOpened,
workflowDiagram,
}: {
rightDrawerState: CommandMenuAnimationVariant;
noAnimation?: boolean;
workflowDiagramFlowInitialized: boolean;
isInRightDrawer: boolean;
isCommandMenuOpened: boolean;
workflowDiagram: WorkflowDiagram | undefined;
}) => {
if (
@@ -270,27 +262,29 @@ export const WorkflowDiagramCanvasBase = ({
setWorkflowDiagramWaitingNodesDimensions(false);
let visibleRightDrawerWidth = 0;
if (rightDrawerState === 'normal' && !isInRightDrawer) {
const rightDrawerWidth = Number(
THEME_COMMON.rightDrawerWidth.replace('px', ''),
);
const baseContainerWidth = containerRef.current.offsetWidth;
const hasViewportBeenMoved = currentViewport.x !== 0;
visibleRightDrawerWidth = rightDrawerWidth;
let adjustedContainerWidth = baseContainerWidth;
if (isCommandMenuOpened) {
adjustedContainerWidth =
baseContainerWidth - COMMAND_MENU_SIDE_PANEL_WIDTH;
} else if (hasViewportBeenMoved) {
adjustedContainerWidth =
baseContainerWidth + COMMAND_MENU_SIDE_PANEL_WIDTH;
}
const flowBounds = reactflow.getNodesBounds(nodes);
const viewportX =
(containerRef.current.offsetWidth + visibleRightDrawerWidth) / 2 -
flowBounds.width / 2;
const centeredXPosition =
adjustedContainerWidth / 2 - flowBounds.width / 2;
reactflow.setViewport(
{
...currentViewport,
x: viewportX - visibleRightDrawerWidth,
x: centeredXPosition,
zoom: defaultFitViewOptions.maxZoom,
},
{ duration: noAnimation ? 0 : 300 },
{ duration: hasViewportBeenMoved ? 300 : 0 },
);
},
[reactflow, setWorkflowDiagramWaitingNodesDimensions],
@@ -299,17 +293,14 @@ export const WorkflowDiagramCanvasBase = ({
const handleSetFlowViewportOnChange = useRecoilCallback(
({ snapshot }) =>
({
rightDrawerState,
workflowDiagramFlowInitialized,
isInRightDrawer,
isCommandMenuOpened,
}: {
rightDrawerState: CommandMenuAnimationVariant;
workflowDiagramFlowInitialized: boolean;
isInRightDrawer: boolean;
isCommandMenuOpened: boolean;
}) => {
setFlowViewport({
rightDrawerState,
isInRightDrawer,
isCommandMenuOpened,
workflowDiagramFlowInitialized,
workflowDiagram: getSnapshotValue(snapshot, workflowDiagramState),
});
@@ -319,14 +310,12 @@ export const WorkflowDiagramCanvasBase = ({
useEffect(() => {
handleSetFlowViewportOnChange({
rightDrawerState,
workflowDiagramFlowInitialized,
isInRightDrawer,
isCommandMenuOpened,
});
}, [
handleSetFlowViewportOnChange,
isInRightDrawer,
rightDrawerState,
isCommandMenuOpened,
workflowDiagramFlowInitialized,
]);
@@ -356,16 +345,13 @@ export const WorkflowDiagramCanvasBase = ({
}
setFlowViewport({
rightDrawerState,
noAnimation: true,
isInRightDrawer,
isCommandMenuOpened,
workflowDiagramFlowInitialized,
workflowDiagram: updatedWorkflowDiagram,
});
},
[
isInRightDrawer,
rightDrawerState,
isCommandMenuOpened,
setFlowViewport,
workflowDiagramFlowInitialized,
workflowDiagramState,
@@ -373,33 +359,15 @@ export const WorkflowDiagramCanvasBase = ({
],
);
const handleInit = useRecoilCallback(
({ snapshot }) =>
() => {
if (!isDefined(containerRef.current)) {
return;
}
const handleInit = () => {
if (!isDefined(containerRef.current)) {
return;
}
setFlowViewport({
rightDrawerState,
noAnimation: true,
isInRightDrawer,
workflowDiagramFlowInitialized: true,
workflowDiagram: getSnapshotValue(snapshot, workflowDiagramState),
});
setWorkflowDiagramFlowInitialized(true);
setWorkflowDiagramFlowInitialized(true);
onInit?.();
},
[
isInRightDrawer,
onInit,
rightDrawerState,
setFlowViewport,
workflowDiagramState,
],
);
onInit?.();
};
const onBeforeDelete: OnBeforeDelete<
WorkflowDiagramNode,
@@ -64,6 +64,7 @@ export const WorkflowDiagramEmptyTriggerReadonly = () => {
workflowVersionId: workflowVisualizerWorkflowVersionId,
title: t`Add a Trigger`,
icon: getIcon(null),
stepId: TRIGGER_STEP_ID,
});
};
@@ -57,6 +57,7 @@ export const WorkflowDiagramStepNodeEditable = ({
workflowVisualizerWorkflowId,
data.name,
getIcon(getWorkflowNodeIconKey(data)),
id,
);
return;
@@ -75,6 +75,7 @@ export const WorkflowDiagramStepNodeReadonly = ({
workflowVersionId: workflowVisualizerWorkflowVersionId,
title: data.name,
icon: getIcon(getWorkflowNodeIconKey(data)),
stepId: id,
});
};
@@ -1,4 +1,3 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { workflowRunIteratorSubStepIterationIndexComponentState } from '@/command-menu/pages/workflow/step/view-run/states/workflowRunIteratorSubStepIterationIndexComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
@@ -8,13 +7,9 @@ import { WorkflowRunStepJsonContainer } from '@/workflow/workflow-steps/componen
import { getIsDescendantOfIterator } from '@/workflow/workflow-steps/utils/getIsDescendantOfIterator';
import { getWorkflowRunStepContext } from '@/workflow/workflow-steps/utils/getWorkflowRunStepContext';
import { getWorkflowVariablesUsedInStep } from '@/workflow/workflow-steps/utils/getWorkflowVariablesUsedInStep';
import { getActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { getActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconBrackets, useIcons } from 'twenty-ui/display';
import { IconBrackets } from 'twenty-ui/display';
import {
type GetJsonNodeHighlighting,
JsonNestedNode,
@@ -25,9 +20,7 @@ import { type JsonValue } from 'type-fest';
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => {
const { t, i18n } = useLingui();
const { getIcon } = useIcons();
const theme = useTheme();
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const workflowRunId = useWorkflowRunIdOrThrow();
@@ -61,14 +54,6 @@ export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => {
throw new Error('The input tab must be rendered with an action step.');
}
const headerTitle = stepDefinition.definition.name;
const headerIcon = getActionIcon(stepDefinition.definition.type);
const headerIconColor = getActionIconColorOrThrow({
theme,
actionType: stepDefinition.definition.type,
});
const headerType = getActionHeaderTypeOrThrow(stepDefinition.definition.type);
const variablesUsedInStep = getWorkflowVariablesUsedInStep({
step,
});
@@ -115,14 +100,6 @@ export const WorkflowRunStepInputDetail = ({ stepId }: { stepId: string }) => {
return (
<>
<SidePanelHeader
disabled
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={i18n._(headerType)}
/>
<WorkflowRunStepJsonContainer>
<JsonTreeContextProvider
value={{
@@ -1,21 +1,11 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { useWorkflowRun } from '@/workflow/hooks/useWorkflowRun';
import { useWorkflowRunIdOrThrow } from '@/workflow/hooks/useWorkflowRunIdOrThrow';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { WorkflowRunStepJsonContainer } from '@/workflow/workflow-steps/components/WorkflowRunStepJsonContainer';
import { useWorkflowRunStepInfo } from '@/workflow/workflow-steps/hooks/useWorkflowRunStepInfo';
import { getWorkflowRunStepInfoToDisplayAsOutput } from '@/workflow/workflow-steps/utils/getWorkflowRunStepInfoToDisplayAsOutput';
import { getActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { getActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { getTriggerIconColor } from '@/workflow/workflow-trigger/utils/getTriggerIconColor';
import { useTheme } from '@emotion/react';
import { useLingui } from '@lingui/react/macro';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import {
type GetJsonNodeHighlighting,
isTwoFirstDepths,
@@ -24,9 +14,7 @@ import {
import { useCopyToClipboard } from '~/hooks/useCopyToClipboard';
export const WorkflowRunStepOutputDetail = ({ stepId }: { stepId: string }) => {
const { t, i18n } = useLingui();
const theme = useTheme();
const { getIcon } = useIcons();
const { t } = useLingui();
const { copyToClipboard } = useCopyToClipboard();
const workflowRunId = useWorkflowRunIdOrThrow();
@@ -51,26 +39,6 @@ export const WorkflowRunStepOutputDetail = ({ stepId }: { stepId: string }) => {
throw new Error('The step is expected to be properly shaped.');
}
const headerTitle = stepDefinition.definition.name;
const headerIcon =
stepDefinition.type === 'trigger'
? getTriggerIcon(stepDefinition.definition)
: getActionIcon(stepDefinition.definition.type);
const headerIconColor =
stepDefinition.type === 'trigger'
? getTriggerIconColor({
theme,
triggerType: stepDefinition.definition.type,
})
: getActionIconColorOrThrow({
theme,
actionType: stepDefinition.definition.type,
});
const headerType =
stepDefinition.type === 'trigger'
? getTriggerHeaderType(stepDefinition.definition)
: i18n._(getActionHeaderTypeOrThrow(stepDefinition.definition.type));
const setRedHighlightingForEveryNode: GetJsonNodeHighlighting = (keyPath) => {
if (keyPath.startsWith('error')) {
return 'red';
@@ -81,16 +49,6 @@ export const WorkflowRunStepOutputDetail = ({ stepId }: { stepId: string }) => {
return (
<>
<SidePanelHeader
disabled
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={
headerTitle ?? getTriggerDefaultLabel(workflowRun.state.flow.trigger)
}
headerType={headerType}
/>
<WorkflowRunStepJsonContainer>
<JsonTree
value={stepInfoToDisplay ?? t`No output available`}
@@ -44,7 +44,11 @@ export const WorkflowStepFooter = ({
dropdownId={dropdownId}
data-select-disable
clickableComponent={
<Button title={t`Options`} hotkeys={[getOsControlSymbol(), 'O']} />
<Button
size="small"
title={t`Options`}
hotkeys={[getOsControlSymbol(), 'O']}
/>
}
dropdownPlacement="top-end"
dropdownOffset={{ y: parseInt(theme.spacing(2), 10) }}
@@ -99,6 +103,7 @@ export const WorkflowStepFooter = ({
const deleteButton = (
<Button
size="small"
title={t`Delete`}
onClick={() => {
deleteStep(stepId);
@@ -0,0 +1,94 @@
import { renderHook } from '@testing-library/react';
import { useUpdateAgentLabel } from '../useUpdateAgentLabel';
const mockUpdateAgent = jest.fn();
const mockUseFindOneAgentQuery = jest.fn();
jest.mock('~/generated-metadata/graphql', () => ({
useFindOneAgentQuery: jest.fn(),
useUpdateOneAgentMutation: () => [mockUpdateAgent],
}));
describe('useUpdateAgentLabel', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseFindOneAgentQuery.mockReturnValue({
data: undefined,
});
(
require('~/generated-metadata/graphql').useFindOneAgentQuery as jest.Mock
).mockImplementation(mockUseFindOneAgentQuery);
});
it('should skip query when agentId is undefined', () => {
renderHook(() => useUpdateAgentLabel(undefined));
expect(mockUseFindOneAgentQuery).toHaveBeenCalledWith({
variables: { id: '' },
skip: true,
});
});
it('should fetch agent when agentId is provided', () => {
const mockAgent = {
id: 'agent-123',
label: 'Original Label',
};
mockUseFindOneAgentQuery.mockReturnValue({
data: {
findOneAgent: mockAgent,
},
});
renderHook(() => useUpdateAgentLabel('agent-123'));
expect(mockUseFindOneAgentQuery).toHaveBeenCalledWith({
variables: { id: 'agent-123' },
skip: false,
});
});
it('should update agent label when agent is defined', async () => {
const mockAgent = {
id: 'agent-123',
label: 'Original Label',
};
mockUseFindOneAgentQuery.mockReturnValue({
data: {
findOneAgent: mockAgent,
},
});
mockUpdateAgent.mockResolvedValue({});
const { result } = renderHook(() => useUpdateAgentLabel('agent-123'));
await result.current.updateAgentLabel('New Label');
expect(mockUpdateAgent).toHaveBeenCalledWith({
variables: {
input: {
id: 'agent-123',
label: 'New Label',
},
},
refetchQueries: ['FindOneAgent'],
});
});
it('should not update agent label when agent is undefined', async () => {
mockUseFindOneAgentQuery.mockReturnValue({
data: {
findOneAgent: undefined,
},
});
const { result } = renderHook(() => useUpdateAgentLabel('agent-123'));
await result.current.updateAgentLabel('New Label');
expect(mockUpdateAgent).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,34 @@
import { isDefined } from 'twenty-shared/utils';
import {
useFindOneAgentQuery,
useUpdateOneAgentMutation,
} from '~/generated-metadata/graphql';
export const useUpdateAgentLabel = (agentId: string | undefined) => {
const { data: agentData } = useFindOneAgentQuery({
variables: { id: agentId || '' },
skip: !isDefined(agentId),
});
const [updateAgent] = useUpdateOneAgentMutation();
const agent = agentData?.findOneAgent;
const updateAgentLabel = async (newLabel: string) => {
if (!isDefined(agent)) {
return;
}
await updateAgent({
variables: {
input: {
id: agent.id,
label: newLabel,
},
},
refetchQueries: ['FindOneAgent'],
});
};
return { updateAgentLabel };
};
@@ -1,5 +1,4 @@
import { useAiModelOptions } from '@/ai/hooks/useAiModelOptions';
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { Select } from '@/ui/input/components/Select';
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
@@ -7,8 +6,6 @@ import { type WorkflowAiAgentAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { useUpdateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep';
import { AI_AGENT_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/AiAgentAction';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { t } from '@lingui/core/macro';
import {
@@ -16,7 +13,6 @@ import {
type ModelConfiguration,
} from 'twenty-shared/ai';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { useDebouncedCallback } from 'use-debounce';
import {
useFindOneAgentQuery,
@@ -40,13 +36,6 @@ export const WorkflowEditActionAiAgent = ({
action,
actionOptions,
}: WorkflowEditActionAiAgentProps) => {
const { getIcon } = useIcons();
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: AI_AGENT_ACTION.defaultLabel,
});
const agentId = action.settings.input.agentId;
const { data: agentData, loading: agentLoading } = useFindOneAgentQuery({
variables: { id: agentId || '' },
@@ -157,34 +146,6 @@ export const WorkflowEditActionAiAgent = ({
<RightDrawerSkeletonLoader />
) : (
<>
<SidePanelHeader
onTitleChange={async (newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate?.({ ...action, name: newName });
// Also update agent label
if (isDefined(agent)) {
await updateAgent({
variables: {
input: {
id: agent.id,
label: newName,
},
},
refetchQueries: ['FindOneAgent'],
});
}
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={actionOptions.readonly}
iconTooltip={AI_AGENT_ACTION.defaultLabel}
/>
<WorkflowStepBody>
<FormTextFieldInput
multiline
@@ -1,4 +1,3 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages';
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { useFullScreenModal } from '@/ui/layout/fullscreen/hooks/useFullScreenModal';
@@ -29,9 +28,6 @@ import { WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/workflow/w
import { serverlessFunctionTestDataFamilyState } from '@/workflow/workflow-steps/workflow-actions/code-action/states/serverlessFunctionTestDataFamilyState';
import { WorkflowServerlessFunctionTabId } from '@/workflow/workflow-steps/workflow-actions/code-action/types/WorkflowServerlessFunctionTabId';
import { getWrongExportedFunctionMarkers } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getWrongExportedFunctionMarkers';
import { useActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionHeaderTypeOrThrow';
import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionIconColorOrThrow';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
@@ -49,7 +45,7 @@ import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { isDefined } from 'twenty-shared/utils';
import { buildOutputSchemaFromValue } from 'twenty-shared/workflow';
import { IconCode, IconPlayerPlay, useIcons } from 'twenty-ui/display';
import { IconCode, IconPlayerPlay } from 'twenty-ui/display';
import { CodeEditor } from 'twenty-ui/input';
import { useIsMobile } from 'twenty-ui/utilities';
import { useDebouncedCallback } from 'use-debounce';
@@ -95,7 +91,6 @@ export const WorkflowEditActionServerlessFunction = ({
action,
actionOptions,
}: WorkflowEditActionServerlessFunctionProps) => {
const { getIcon } = useIcons();
const { t } = useLingui();
const [isFullScreen, setIsFullScreen] = useState(false);
const isMobile = useIsMobile();
@@ -329,13 +324,6 @@ export const WorkflowEditActionServerlessFunction = ({
dependencies: [isFullScreen],
});
const headerTitle = isDefined(action.name)
? action.name
: CODE_ACTION.defaultLabel;
const headerIcon = getActionIcon(action.type);
const headerIconColor = useActionIconColorOrThrow(action.type);
const headerType = useActionHeaderTypeOrThrow(action.type);
const testLogsTextAreaId = `${serverlessFunctionId}-test-logs`;
const breadcrumbLinks: BreadcrumbProps['links'] = [
@@ -344,7 +332,7 @@ export const WorkflowEditActionServerlessFunction = ({
href: '#',
},
{
children: headerTitle,
children: isDefined(action.name) ? action.name : CODE_ACTION.defaultLabel,
href: '#',
},
{
@@ -427,17 +415,6 @@ export const WorkflowEditActionServerlessFunction = ({
WORKFLOW_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID
}
/>
<SidePanelHeader
onTitleChange={(newName: string) => {
updateAction({ name: newName });
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={actionOptions.readonly}
iconTooltip={CODE_ACTION.defaultLabel}
/>
<WorkflowStepBody>
{activeTabId === WorkflowServerlessFunctionTabId.CODE && (
<>
@@ -1,4 +1,3 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages';
import { useServerlessFunctionUpdateFormState } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState';
import { type WorkflowCodeAction } from '@/workflow/types/Workflow';
@@ -7,15 +6,10 @@ import { INDEX_FILE_NAME } from '@/serverless-functions/constants/IndexFileName'
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowEditActionServerlessFunctionFields } from '@/workflow/workflow-steps/workflow-actions/code-action/components/WorkflowEditActionServerlessFunctionFields';
import { getWrongExportedFunctionMarkers } from '@/workflow/workflow-steps/workflow-actions/code-action/utils/getWrongExportedFunctionMarkers';
import { useActionHeaderTypeOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionHeaderTypeOrThrow';
import { useActionIconColorOrThrow } from '@/workflow/workflow-steps/workflow-actions/hooks/useActionIconColorOrThrow';
import { getActionIcon } from '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon';
import styled from '@emotion/styled';
import { type Monaco } from '@monaco-editor/react';
import { type editor } from 'monaco-editor';
import { AutoTypings } from 'monaco-editor-auto-typings';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { CodeEditor } from 'twenty-ui/input';
import { SOURCE_FOLDER_NAME } from '@/serverless-functions/constants/SourceFolderName';
@@ -31,7 +25,6 @@ type WorkflowReadonlyActionServerlessFunctionProps = {
export const WorkflowReadonlyActionServerlessFunction = ({
action,
}: WorkflowReadonlyActionServerlessFunctionProps) => {
const { getIcon } = useIcons();
const serverlessFunctionId = action.settings.input.serverlessFunctionId;
const serverlessFunctionVersion =
action.settings.input.serverlessFunctionVersion;
@@ -58,13 +51,6 @@ export const WorkflowReadonlyActionServerlessFunction = ({
});
};
const headerTitle = isDefined(action.name)
? action.name
: 'Code - Serverless Function';
const headerIcon = getActionIcon(action.type);
const headerIconColor = useActionIconColorOrThrow(action.type);
const headerType = useActionHeaderTypeOrThrow(action.type);
if (loading) {
return null;
}
@@ -77,13 +63,6 @@ export const WorkflowReadonlyActionServerlessFunction = ({
return (
<>
<SidePanelHeader
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled
/>
<WorkflowStepBody>
<WorkflowEditActionServerlessFunctionFields
functionInput={action.settings.input.serverlessFunctionInput}
@@ -1,13 +1,9 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { type WorkflowCreateRecordAction } from '@/workflow/types/Workflow';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import {
WorkflowCreateRecordBody,
type CreateRecordFormData,
} from '@/workflow/workflow-steps/workflow-actions/components/WorkflowCreateRecordBody';
import { CREATE_RECORD_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/CreateRecordAction';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { useIcons } from 'twenty-ui/display';
type WorkflowEditActionCreateRecordProps = {
action: WorkflowCreateRecordAction;
@@ -25,16 +21,8 @@ export const WorkflowEditActionCreateRecord = ({
action,
actionOptions,
}: WorkflowEditActionCreateRecordProps) => {
const { getIcon } = useIcons();
const isFormDisabled = actionOptions.readonly;
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: CREATE_RECORD_ACTION.defaultLabel,
});
const handleUpdate = (formData: CreateRecordFormData) => {
if (actionOptions.readonly === true) {
return;
@@ -56,24 +44,6 @@ export const WorkflowEditActionCreateRecord = ({
return (
<>
<SidePanelHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={isFormDisabled}
iconTooltip={CREATE_RECORD_ACTION.defaultLabel}
/>
<WorkflowCreateRecordBody
defaultObjectName={action.settings.input.objectName}
defaultObjectRecord={action.settings.input.objectRecord}
@@ -1,4 +1,3 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker';
import { Select } from '@/ui/input/components/Select';
@@ -8,8 +7,6 @@ import { useEffect, useState } from 'react';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { DELETE_RECORD_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/DeleteRecordAction';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useTheme } from '@emotion/react';
import { isDefined } from 'twenty-shared/utils';
@@ -116,32 +113,8 @@ export const WorkflowEditActionDeleteRecord = ({
};
}, [saveAction]);
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: DELETE_RECORD_ACTION.defaultLabel,
});
return (
<>
<SidePanelHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={isFormDisabled}
iconTooltip={DELETE_RECORD_ACTION.defaultLabel}
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-action-record-delete-object-name"
@@ -4,7 +4,6 @@ import { type ConnectedAccount } from '@/accounts/types/ConnectedAccount';
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
import { WorkflowSendEmailAttachments } from '@/advanced-text-editor/components/WorkflowSendEmailAttachments';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
@@ -20,8 +19,6 @@ import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/wo
import { type WorkflowSendEmailAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { SEND_EMAIL_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/SendEmailAction';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
@@ -29,7 +26,7 @@ import { useEffect, useState } from 'react';
import { useRecoilValue } from 'recoil';
import { ConnectedAccountProvider, SettingsPath } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { IconPlus, useIcons } from 'twenty-ui/display';
import { IconPlus } from 'twenty-ui/display';
import { type SelectOption } from 'twenty-ui/input';
import { type JsonValue } from 'type-fest';
import { useDebouncedCallback } from 'use-debounce';
@@ -72,7 +69,6 @@ export const WorkflowEditActionSendEmail = ({
actionOptions,
}: WorkflowEditActionSendEmailProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { triggerApisOAuth } = useTriggerApisOAuth();
const { enqueueErrorSnackBar } = useSnackBar();
@@ -257,12 +253,6 @@ export const WorkflowEditActionSendEmail = ({
}
});
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: SEND_EMAIL_ACTION.defaultLabel,
});
const navigate = useNavigateSettings();
const { closeCommandMenu } = useCommandMenu();
@@ -270,24 +260,6 @@ export const WorkflowEditActionSendEmail = ({
return (
!loading && (
<>
<SidePanelHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={actionOptions.readonly}
iconTooltip={SEND_EMAIL_ACTION.defaultLabel}
/>
<WorkflowStepBody>
<Select
dropdownId="select-connected-account-id"
@@ -346,7 +318,7 @@ export const WorkflowEditActionSendEmail = ({
href: '#',
},
{
children: headerTitle,
children: isDefined(action.name) ? action.name : t`Send Email`,
href: '#',
},
{
@@ -1,4 +1,3 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { formatFieldMetadataItemAsFieldDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsFieldDefinition';
import { FormFieldInput } from '@/object-record/record-field/ui/components/FormFieldInput';
@@ -10,8 +9,6 @@ import { WorkflowFieldsMultiSelect } from '@/workflow/components/WorkflowEditUpd
import { type WorkflowUpdateRecordAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { UPDATE_RECORD_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/UpdateRecordAction';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { type UpdateRecordFormData } from '@/workflow/workflow-steps/workflow-actions/types/update-record-form-data.type';
import { shouldDisplayFormField } from '@/workflow/workflow-steps/workflow-actions/utils/shouldDisplayFormField';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
@@ -71,12 +68,6 @@ export const WorkflowEditActionUpdateRecord = ({
const isFormDisabled = actionOptions.readonly === true;
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: UPDATE_RECORD_ACTION.defaultLabel,
});
const handleFieldChange = (
fieldName: keyof UpdateRecordFormData,
updatedValue: JsonValue,
@@ -156,24 +147,6 @@ export const WorkflowEditActionUpdateRecord = ({
return (
<>
<SidePanelHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={isFormDisabled}
iconTooltip={UPDATE_RECORD_ACTION.defaultLabel}
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-update-record-object-name"
@@ -1,4 +1,3 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { type WorkflowUpsertRecordAction } from '@/workflow/types/Workflow';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import {
@@ -6,10 +5,6 @@ import {
type CreateRecordFormData,
} from '@/workflow/workflow-steps/workflow-actions/components/WorkflowCreateRecordBody';
import { UPSERT_RECORD_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/UpsertRecordAction';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { useIcons } from 'twenty-ui/display';
type WorkflowEditActionUpsertRecordProps = {
action: WorkflowUpsertRecordAction;
actionOptions:
@@ -26,14 +21,6 @@ export const WorkflowEditActionUpsertRecord = ({
action,
actionOptions,
}: WorkflowEditActionUpsertRecordProps) => {
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: UPSERT_RECORD_ACTION.defaultLabel,
});
const { getIcon } = useIcons();
const isFormDisabled = actionOptions.readonly === true;
const handleUpdate = (formData: CreateRecordFormData) => {
@@ -57,24 +44,6 @@ export const WorkflowEditActionUpsertRecord = ({
return (
<>
<SidePanelHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={isFormDisabled}
iconTooltip={UPSERT_RECORD_ACTION.defaultLabel}
/>
<WorkflowCreateRecordBody
defaultObjectName={action.settings.input.objectName}
defaultObjectRecord={action.settings.input.objectRecord}
@@ -72,15 +72,6 @@ export const Disabled: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleText = await canvas.findByText('Create Record');
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.click(titleText);
const titleInput = canvas.queryByDisplayValue('Create Record');
expect(titleInput).not.toBeInTheDocument();
const objectSelectCurrentValue = await canvas.findByText('People');
await userEvent.click(objectSelectCurrentValue);
@@ -77,15 +77,6 @@ export const DisabledWithEmptyValues: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleText = await canvas.findByText('Delete Record');
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.click(titleText);
const titleInput = canvas.queryByDisplayValue('Delete Record');
expect(titleInput).not.toBeInTheDocument();
const objectSelectCurrentValue = await canvas.findByText('People');
await userEvent.click(objectSelectCurrentValue);
@@ -128,15 +119,6 @@ export const DisabledWithDefaultStaticValues: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleText = await canvas.findByText('Delete Record');
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.click(titleText);
const titleInput = canvas.queryByDisplayValue('Delete Record');
expect(titleInput).not.toBeInTheDocument();
const objectSelectCurrentValue = await canvas.findByText('People');
await userEvent.click(objectSelectCurrentValue);
@@ -187,15 +169,6 @@ export const DisabledWithDefaultVariableValues: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleText = await canvas.findByText('Delete Record');
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.click(titleText);
const titleInput = canvas.queryByDisplayValue('Delete Record');
expect(titleInput).not.toBeInTheDocument();
const objectSelectCurrentValue = await canvas.findByText('People');
await userEvent.click(objectSelectCurrentValue);
@@ -76,15 +76,6 @@ export const DisabledWithEmptyValues: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleText = await canvas.findByText('Search Records');
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.click(titleText);
const titleInput = canvas.queryByDisplayValue('Search Records');
expect(titleInput).not.toBeInTheDocument();
const objectSelectCurrentValue = await canvas.findByText('People');
await userEvent.click(objectSelectCurrentValue);
@@ -112,7 +112,6 @@ export const Default: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Send Email')).toBeVisible();
expect(await canvas.findByText('Account')).toBeVisible();
expect(await canvas.findByText('Subject')).toBeVisible();
expect(await canvas.findByText('Body')).toBeVisible();
@@ -129,8 +128,6 @@ export const Configured: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
expect(await canvas.findByText('Send Welcome Email')).toBeVisible();
expect(await canvas.findByText('Account')).toBeVisible();
expect(await canvas.findByText('Subject')).toBeVisible();
expect(await canvas.findByText('Body')).toBeVisible();
@@ -90,15 +90,6 @@ export const DisabledWithEmptyValues: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleText = await canvas.findByText('Update Record');
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.click(titleText);
const titleInput = canvas.queryByDisplayValue('Update Record');
expect(titleInput).not.toBeInTheDocument();
const objectSelectCurrentValue = await canvas.findByText('People');
await userEvent.click(objectSelectCurrentValue);
@@ -152,15 +143,6 @@ export const DisabledWithDefaultStaticValues: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleText = await canvas.findByText('Update Record');
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.click(titleText);
const titleInput = canvas.queryByDisplayValue('Update Record');
expect(titleInput).not.toBeInTheDocument();
const objectSelectCurrentValue = await canvas.findByText('People');
await userEvent.click(objectSelectCurrentValue);
@@ -216,15 +198,6 @@ export const DisabledWithDefaultVariableValues: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleText = await canvas.findByText('Update Record');
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.click(titleText);
const titleInput = canvas.queryByDisplayValue('Update Record');
expect(titleInput).not.toBeInTheDocument();
const objectSelectCurrentValue = await canvas.findByText('People');
await userEvent.click(objectSelectCurrentValue);
@@ -1,4 +1,3 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { FormDateTimeFieldInput } from '@/object-record/record-field/ui/form-types/components/FormDateTimeFieldInput';
import { FormNumberFieldInput } from '@/object-record/record-field/ui/form-types/components/FormNumberFieldInput';
import { Select } from '@/ui/input/components/Select';
@@ -6,8 +5,6 @@ import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/Gene
import { type WorkflowDelayAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { DELAY_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/DelayAction';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { t } from '@lingui/core/macro';
import {
@@ -32,12 +29,6 @@ export const WorkflowEditActionDelay = ({
action,
actionOptions,
}: WorkflowEditActionDelayProps) => {
const { headerTitle, headerIcon, headerIconColor, headerType, getIcon } =
useWorkflowActionHeader({
action,
defaultTitle: DELAY_ACTION.defaultLabel,
});
const delayOptions: Array<SelectOption<'SCHEDULED_DATE' | 'DURATION'>> = [
{
label: t`At a specific date or time`,
@@ -139,28 +130,8 @@ export const WorkflowEditActionDelay = ({
});
};
const HeaderIcon = getIcon(headerIcon ?? 'IconPlayerPause');
return (
<>
<SidePanelHeader
initialTitle={headerTitle}
Icon={HeaderIcon}
iconColor={headerIconColor}
headerType={headerType}
onTitleChange={(newTitle: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newTitle,
});
}}
iconTooltip={DELAY_ACTION.defaultLabel}
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-action-delay-type"
@@ -1,14 +1,10 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { type WorkflowFilterAction } from '@/workflow/types/Workflow';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { FILTER_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/FilterAction';
import { WorkflowEditActionFilterBody } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilterBody';
import { WorkflowEditActionFilterBodyEffect } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilterBodyEffect';
import { StepFilterGroupsComponentInstanceContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/StepFilterGroupsComponentInstanceContext';
import { StepFiltersComponentInstanceContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/StepFiltersComponentInstanceContext';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { type StepFilter, type StepFilterGroup } from 'twenty-shared/types';
import { useIcons } from 'twenty-ui/display';
type WorkflowEditActionFilterProps = {
action: WorkflowFilterAction;
@@ -31,34 +27,8 @@ export const WorkflowEditActionFilter = ({
action,
actionOptions,
}: WorkflowEditActionFilterProps) => {
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: FILTER_ACTION.defaultLabel,
});
const { getIcon } = useIcons();
return (
<>
<SidePanelHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={actionOptions.readonly}
iconTooltip={FILTER_ACTION.defaultLabel}
/>
<StepFiltersComponentInstanceContext.Provider
value={{
instanceId: action.id,
@@ -1,4 +1,3 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select } from '@/ui/input/components/Select';
import { type WorkflowFindRecordsAction } from '@/workflow/types/Workflow';
@@ -17,11 +16,9 @@ import { type RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { InputLabel } from '@/ui/input/components/InputLabel';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { FIND_RECORDS_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/FindRecordsAction';
import { WorkflowFindRecordsFilters } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowFindRecordsFilters';
import { WorkflowFindRecordsFiltersEffect } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowFindRecordsFiltersEffect';
import { WorkflowFindRecordsSorts } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowFindRecordsSorts';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { useLingui } from '@lingui/react/macro';
import { isNumber } from '@sniptt/guards';
import { QUERY_MAX_RECORDS } from 'twenty-shared/constants';
@@ -147,32 +144,8 @@ export const WorkflowEditActionFindRecords = ({
};
}, [saveAction]);
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: FIND_RECORDS_ACTION.defaultLabel,
});
return (
<>
<SidePanelHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={isFormDisabled}
iconTooltip={FIND_RECORDS_ACTION.defaultLabel}
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-action-record-find-records-object-name"
@@ -1,4 +1,3 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { FormFieldInputContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputContainer';
import { FormFieldInputInnerContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputInnerContainer';
import { FormFieldInputRowContainer } from '@/object-record/record-field/ui/form-types/components/FormFieldInputRowContainer';
@@ -9,12 +8,10 @@ import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableLi
import { type WorkflowFormAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { FORM_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/FormAction';
import { WorkflowEditActionFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormFieldSettings';
import { WorkflowFormEmptyMessage } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowFormEmptyMessage';
import { type WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { getDefaultFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { css, useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { type OnDragEndResponder } from '@hello-pangea/dnd';
@@ -28,7 +25,6 @@ import {
IconGripVertical,
IconPlus,
IconTrash,
useIcons,
} from 'twenty-ui/display';
import { LightIconButton } from 'twenty-ui/input';
import { useDebouncedCallback } from 'use-debounce';
@@ -136,17 +132,10 @@ export const WorkflowEditActionFormBuilder = ({
actionOptions,
}: WorkflowEditActionFormBuilderProps) => {
const theme = useTheme();
const { getIcon } = useIcons();
const { t } = useLingui();
const [formData, setFormData] = useState<FormData>(action.settings.input);
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: FORM_ACTION.defaultLabel,
});
const [selectedField, setSelectedField] = useState<string | null>(null);
const [hoveredField, setHoveredField] = useState<string | null>(null);
@@ -223,24 +212,6 @@ export const WorkflowEditActionFormBuilder = ({
return (
<>
<SidePanelHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate({
...action,
name: newName,
});
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={actionOptions.readonly}
iconTooltip={FORM_ACTION.defaultLabel}
/>
<StyledWorkflowStepBody>
{formData.length === 0 && (
<WorkflowFormEmptyMessage data-testid="empty-form-message" />
@@ -1,5 +1,4 @@
import { CmdEnterActionButton } from '@/action-menu/components/CmdEnterActionButton';
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { useCommandMenuHistory } from '@/command-menu/hooks/useCommandMenuHistory';
import { FormFieldInput } from '@/object-record/record-field/ui/components/FormFieldInput';
import { FormSingleRecordPicker } from '@/object-record/record-field/ui/form-types/components/FormSingleRecordPicker';
@@ -10,15 +9,12 @@ import { type WorkflowFormAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { useUpdateWorkflowRunStep } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowRunStep';
import { WorkflowFormFieldInput } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowFormFieldInput';
import { FORM_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/FormAction';
import { useSubmitFormStep } from '@/workflow/workflow-steps/workflow-actions/form-action/hooks/useSubmitFormStep';
import { type WorkflowFormActionField } from '@/workflow/workflow-steps/workflow-actions/form-action/types/WorkflowFormActionField';
import { getDefaultFormFieldSettings } from '@/workflow/workflow-steps/workflow-actions/form-action/utils/getDefaultFormFieldSettings';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { useLingui } from '@lingui/react/macro';
import { useEffect, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui/display';
import { useDebouncedCallback } from 'use-debounce';
export type WorkflowEditActionFormFillerProps = {
@@ -35,7 +31,6 @@ export const WorkflowEditActionFormFiller = ({
actionOptions,
}: WorkflowEditActionFormFillerProps) => {
const { t } = useLingui();
const { getIcon } = useIcons();
const { submitFormStep } = useSubmitFormStep();
const [formData, setFormData] = useState<FormData>(action.settings.input);
const workflowRunId = useWorkflowRunIdOrThrow();
@@ -45,12 +40,6 @@ export const WorkflowEditActionFormFiller = ({
const canSubmit = !actionOptions.readonly && !isDefined(error);
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: FORM_ACTION.defaultLabel,
});
const onFieldUpdate = ({
fieldId,
value,
@@ -111,14 +100,6 @@ export const WorkflowEditActionFormFiller = ({
return (
<>
<SidePanelHeader
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled
iconTooltip={FORM_ACTION.defaultLabel}
/>
<WorkflowStepBody>
{formData.map((field) => {
if (field.type === 'RECORD') {
@@ -159,15 +159,6 @@ export const DisabledWithEmptyValues: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const titleText = await canvas.findByText('Form');
expect(window.getComputedStyle(titleText).cursor).toBe('default');
await userEvent.click(titleText);
const titleInput = canvas.queryByDisplayValue('Form');
expect(titleInput).not.toBeInTheDocument();
await canvas.findByText('Company');
const addFieldButton = canvas.queryByText('Add Field');
@@ -1,223 +0,0 @@
import {
type WorkflowFormAction,
type WorkflowHttpRequestAction,
type WorkflowSendEmailAction,
} from '@/workflow/types/Workflow';
import { renderHook } from '@testing-library/react';
import { FieldMetadataType } from 'twenty-shared/types';
import {
workflowFormActionSettingsSchema,
workflowHttpRequestActionSettingsSchema,
workflowSendEmailActionSettingsSchema,
} from 'twenty-shared/workflow';
import { useWorkflowActionHeader } from '../useWorkflowActionHeader';
jest.mock('../useActionIconColorOrThrow', () => ({
useActionIconColorOrThrow: jest.fn().mockReturnValue('blue'),
}));
jest.mock('../useActionHeaderTypeOrThrow', () => ({
useActionHeaderTypeOrThrow: jest.fn().mockReturnValue('Action'),
}));
jest.mock('../../utils/getActionIcon', () => ({
getActionIcon: jest.fn().mockReturnValue('IconHttp'),
}));
const mockGetIcon = jest.fn().mockReturnValue('IconComponent');
jest.mock('twenty-ui/display', () => ({
useIcons: () => ({
getIcon: mockGetIcon,
}),
}));
describe('useWorkflowActionHeader', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('when action name is not defined', () => {
it('should return default title', () => {
const action = {
id: '1',
name: '',
type: 'HTTP_REQUEST',
settings: workflowHttpRequestActionSettingsSchema.parse({
input: {
url: 'https://example.com',
method: 'GET',
headers: {},
body: {},
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
}),
valid: true,
} satisfies WorkflowHttpRequestAction;
const { result } = renderHook(() =>
useWorkflowActionHeader({
action,
defaultTitle: 'HTTP Request',
}),
);
expect(result.current.headerTitle).toBe('HTTP Request');
expect(result.current.headerIcon).toBe('IconHttp');
expect(result.current.headerIconColor).toBe('blue');
expect(result.current.headerType).toBe('Action');
expect(result.current.getIcon).toBe(mockGetIcon);
});
});
describe('when action name is defined', () => {
it('should return the action name', () => {
const action = {
id: '1',
name: 'Test Action',
type: 'HTTP_REQUEST',
settings: workflowHttpRequestActionSettingsSchema.parse({
input: {
url: 'https://example.com',
method: 'GET',
headers: {},
body: {},
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
}),
valid: true,
} satisfies WorkflowHttpRequestAction;
const { result } = renderHook(() =>
useWorkflowActionHeader({
action,
defaultTitle: 'HTTP Request',
}),
);
expect(result.current.headerTitle).toBe('Test Action');
expect(result.current.headerIcon).toBe('IconHttp');
expect(result.current.headerIconColor).toBe('blue');
expect(result.current.headerType).toBe('Action');
expect(result.current.getIcon).toBe(mockGetIcon);
});
});
describe('when action type is defined', () => {
it('should return default title for HTTP request action', () => {
const action = {
id: '1',
name: '',
type: 'HTTP_REQUEST',
settings: workflowHttpRequestActionSettingsSchema.parse({
input: {
url: 'https://example.com',
method: 'GET',
headers: {},
body: {},
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
}),
valid: true,
} satisfies WorkflowHttpRequestAction;
const { result } = renderHook(() =>
useWorkflowActionHeader({
action,
defaultTitle: 'HTTP Request',
}),
);
expect(result.current.headerTitle).toBe('HTTP Request');
expect(result.current.headerIcon).toBe('IconHttp');
expect(result.current.headerIconColor).toBe('blue');
expect(result.current.headerType).toBe('Action');
expect(result.current.getIcon).toBe(mockGetIcon);
});
it('should return default title for form action', () => {
const action = {
id: '1',
name: '',
type: 'FORM',
settings: workflowFormActionSettingsSchema.parse({
input: [
{
id: '1',
name: 'field1',
label: 'Field 1',
type: FieldMetadataType.TEXT,
required: false,
settings: {},
},
],
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
}),
valid: true,
} satisfies WorkflowFormAction;
const { result } = renderHook(() =>
useWorkflowActionHeader({
action,
defaultTitle: 'Form',
}),
);
expect(result.current.headerTitle).toBe('Form');
expect(result.current.headerIcon).toBe('IconHttp');
expect(result.current.headerIconColor).toBe('blue');
expect(result.current.headerType).toBe('Action');
expect(result.current.getIcon).toBe(mockGetIcon);
});
it('should return default title for email action', () => {
const action = {
id: '1',
name: '',
type: 'SEND_EMAIL',
settings: workflowSendEmailActionSettingsSchema.parse({
input: {
connectedAccountId: '1',
email: 'test@example.com',
subject: 'Test Subject',
body: 'Test Body',
},
outputSchema: {},
errorHandlingOptions: {
retryOnFailure: { value: false },
continueOnFailure: { value: false },
},
}),
valid: true,
} satisfies WorkflowSendEmailAction;
const { result } = renderHook(() =>
useWorkflowActionHeader({
action,
defaultTitle: 'Email',
}),
);
expect(result.current.headerTitle).toBe('Email');
expect(result.current.headerIcon).toBe('IconHttp');
expect(result.current.headerIconColor).toBe('blue');
expect(result.current.headerType).toBe('Action');
expect(result.current.getIcon).toBe(mockGetIcon);
});
});
});
@@ -1,38 +0,0 @@
import { type WorkflowAction } from '@/workflow/types/Workflow';
import { type IconComponent, useIcons } from 'twenty-ui/display';
import { getActionIcon } from '../utils/getActionIcon';
import { useActionHeaderTypeOrThrow } from './useActionHeaderTypeOrThrow';
import { useActionIconColorOrThrow } from './useActionIconColorOrThrow';
type UseWorkflowActionHeaderProps = {
action: WorkflowAction;
defaultTitle: string;
};
type UseWorkflowActionHeaderReturn = {
headerTitle: string;
headerIcon: string | undefined;
headerIconColor: string;
headerType: string;
getIcon: (iconName: string) => IconComponent;
};
export const useWorkflowActionHeader = ({
action,
defaultTitle,
}: UseWorkflowActionHeaderProps): UseWorkflowActionHeaderReturn => {
const { getIcon } = useIcons();
const headerTitle = action.name ? action.name : defaultTitle;
const headerIcon = getActionIcon(action.type);
const headerIconColor = useActionIconColorOrThrow(action.type);
const headerType = useActionHeaderTypeOrThrow(action.type);
return {
headerTitle,
headerIcon,
headerIconColor,
headerType,
getIcon,
};
};
@@ -1,11 +1,8 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { FormRawJsonFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRawJsonFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { Select } from '@/ui/input/components/Select';
import { type WorkflowHttpRequestAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { HTTP_REQUEST_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/HttpRequestAction';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { CmdEnterActionButton } from '@/action-menu/components/CmdEnterActionButton';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
@@ -20,7 +17,7 @@ import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { useEffect } from 'react';
import { IconPlayerPlay, IconSettings, useIcons } from 'twenty-ui/display';
import { IconPlayerPlay, IconSettings } from 'twenty-ui/display';
import {
HTTP_METHODS,
JSON_RESPONSE_PLACEHOLDER,
@@ -93,16 +90,10 @@ export const WorkflowEditActionHttpRequest = ({
}: WorkflowEditActionHttpRequestProps) => {
const { t } = useLingui();
const theme = useTheme();
const { getIcon } = useIcons();
const activeTabId = useRecoilComponentValue(
activeTabIdComponentState,
WORKFLOW_HTTP_REQUEST_TAB_LIST_COMPONENT_ID,
);
const { headerTitle, headerIcon, headerIconColor, headerType } =
useWorkflowActionHeader({
action,
defaultTitle: HTTP_REQUEST_ACTION.defaultLabel,
});
const { formData, handleFieldChange, saveAction } = useHttpRequestForm({
action,
@@ -143,20 +134,6 @@ export const WorkflowEditActionHttpRequest = ({
behaveAsLinks={false}
componentInstanceId={WORKFLOW_HTTP_REQUEST_TAB_LIST_COMPONENT_ID}
/>
<SidePanelHeader
onTitleChange={(newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate?.({ ...action, name: newName });
}}
Icon={getIcon(headerIcon)}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={actionOptions.readonly}
iconTooltip={HTTP_REQUEST_ACTION.defaultLabel}
/>
<WorkflowStepBody>
{activeTabId === WorkflowHttpRequestTabId.CONFIGURATION && (
<StyledConfigurationTabContent>
@@ -118,10 +118,6 @@ export const Configured: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const header = await canvas.findByTestId('side-panel-header');
const headerCanvas = within(header);
expect(await headerCanvas.findByText('API Call')).toBeVisible();
const urlLabel = await canvas.findByText('URL');
const urlInputContainer = urlLabel.closest('div')?.nextElementSibling;
const urlEditor = urlInputContainer?.querySelector('.ProseMirror');
@@ -1,12 +1,9 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { FormArrayFieldInput } from '@/object-record/record-field/ui/form-types/components/FormArrayFieldInput';
import { type FieldArrayValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { type WorkflowIteratorAction } from '@/workflow/types/Workflow';
import { isStandaloneVariableString } from '@/workflow/utils/isStandaloneVariableString';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { ITERATOR_ACTION } from '@/workflow/workflow-steps/workflow-actions/constants/actions/IteratorAction';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { useLingui } from '@lingui/react/macro';
import { isArray, isString } from '@sniptt/guards';
@@ -44,12 +41,6 @@ export const WorkflowEditActionIterator = ({
action,
actionOptions,
}: WorkflowEditActionIteratorProps) => {
const { headerTitle, headerIcon, headerIconColor, headerType, getIcon } =
useWorkflowActionHeader({
action,
defaultTitle: ITERATOR_ACTION.defaultLabel,
});
const { t } = useLingui();
const defaultItems = isDefined(action.settings.input.items)
@@ -100,28 +91,8 @@ export const WorkflowEditActionIterator = ({
saveAction(updatedFormData);
};
const handleTitleChange = (newName: string) => {
if (actionOptions.readonly === true) {
return;
}
actionOptions.onActionUpdate?.({
...action,
name: newName,
});
};
return (
<>
<SidePanelHeader
onTitleChange={handleTitleChange}
Icon={getIcon(headerIcon || 'IconRepeat')}
iconColor={headerIconColor}
initialTitle={headerTitle}
headerType={headerType}
disabled={actionOptions.readonly}
iconTooltip={ITERATOR_ACTION.defaultLabel}
/>
<WorkflowStepBody>
<FormArrayFieldInput
label={t`Items to iterate over`}
@@ -1,4 +1,3 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { FormNumberFieldInput } from '@/object-record/record-field/ui/form-types/components/FormNumberFieldInput';
import { FormSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormSelectFieldInput';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
@@ -11,19 +10,13 @@ import {
type CronTriggerInterval,
} from '@/workflow/workflow-trigger/constants/CronTriggerIntervalOptions';
import { getCronTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getCronTriggerDefaultSettings';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { getTriggerIconColor } from '@/workflow/workflow-trigger/utils/getTriggerIconColor';
import { getTriggerScheduleDescription } from '@/workflow/workflow-trigger/utils/getTriggerScheduleDescription';
import { useTheme } from '@emotion/react';
import { t } from '@lingui/core/macro';
import { isNumber } from '@sniptt/guards';
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';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
type WorkflowEditTriggerCronFormProps = {
@@ -53,19 +46,10 @@ export const WorkflowEditTriggerCronForm = ({
trigger,
triggerOptions,
}: WorkflowEditTriggerCronFormProps) => {
const theme = useTheme();
const [errorMessages, setErrorMessages] = useState<FormErrorMessages>({});
const [errorMessagesVisible, setErrorMessagesVisible] = useState(false);
const dateLocale = useRecoilValue(dateLocaleState);
const { getIcon } = useIcons();
const headerIcon = getTriggerIcon(trigger);
const defaultLabel = getTriggerDefaultLabel(trigger);
const headerTitle = trigger.name ?? defaultLabel;
const headerType = getTriggerHeaderType(trigger);
const customDescription = getTriggerScheduleDescription(
trigger,
dateLocale.localeCatalog,
@@ -77,24 +61,6 @@ export const WorkflowEditTriggerCronForm = ({
return (
<>
<SidePanelHeader
onTitleChange={(newName: string) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
name: newName,
});
}}
Icon={getIcon(headerIcon)}
iconColor={getTriggerIconColor({ theme, triggerType: trigger.type })}
initialTitle={headerTitle}
headerType={headerType}
disabled={triggerOptions.readonly}
iconTooltip={getTriggerDefaultLabel(trigger)}
/>
<WorkflowStepBody>
<FormSelectFieldInput
label={t`Trigger interval`}
@@ -1,4 +1,3 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { type FieldMultiSelectValue } from '@/object-record/record-field/ui/types/FieldMetadata';
import { SelectControl } from '@/ui/input/components/SelectControl';
@@ -16,10 +15,6 @@ import { type WorkflowDatabaseEventTrigger } from '@/workflow/types/Workflow';
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType';
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 { Trans } from '@lingui/react/macro';
@@ -122,10 +117,6 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
[systemObjects, searchInputValue],
);
const defaultLabel = trigger.name ?? getTriggerDefaultLabel(trigger);
const headerIcon = getTriggerIcon(trigger);
const headerType = getTriggerHeaderType(trigger);
const handleOptionClick = (value: string) => {
if (triggerOptions.readonly === true) {
return;
@@ -174,24 +165,6 @@ export const WorkflowEditTriggerDatabaseEventForm = ({
return (
<>
<SidePanelHeader
onTitleChange={(newName: string) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
name: newName,
});
}}
Icon={getIcon(headerIcon)}
iconColor={getTriggerIconColor({ theme, triggerType: trigger.type })}
initialTitle={defaultLabel}
headerType={headerType}
disabled={triggerOptions.readonly}
iconTooltip={getTriggerDefaultLabel(trigger)}
/>
<WorkflowStepBody>
<StyledRecordTypeSelectContainer fullWidth>
<StyledLabel>Record Type</StyledLabel>
@@ -1,4 +1,3 @@
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { IconPicker } from '@/ui/input/components/IconPicker';
import { Select } from '@/ui/input/components/Select';
@@ -10,10 +9,6 @@ import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/Workflo
import { MANUAL_TRIGGER_AVAILABILITY_TYPE_OPTIONS } from '@/workflow/workflow-trigger/constants/ManualTriggerAvailabilityTypeOptions';
import { MANUAL_TRIGGER_IS_PINNED_OPTIONS } from '@/workflow/workflow-trigger/constants/ManualTriggerIsPinnedOptions';
import { getManualTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getManualTriggerDefaultSettings';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType';
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 { useLingui } from '@lingui/react/macro';
@@ -76,12 +71,6 @@ export const WorkflowEditTriggerManual = ({
const availability = trigger.settings.availability;
const headerTitle = trigger.name ?? getTriggerDefaultLabel(trigger);
const headerIcon = getTriggerIcon(trigger);
const headerType = getTriggerHeaderType(trigger);
const availabilityDescriptions = {
SINGLE_RECORD: t`The selected record will be passed to your workflow`,
BULK_RECORDS: t`The selected records (up to ${maxRecordsFormatted}) will be passed to your workflow`,
@@ -90,24 +79,6 @@ export const WorkflowEditTriggerManual = ({
return (
<>
<SidePanelHeader
onTitleChange={(newName: string) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate({
...trigger,
name: newName,
});
}}
Icon={getIcon(headerIcon)}
iconColor={getTriggerIconColor({ theme, triggerType: trigger.type })}
initialTitle={headerTitle}
headerType={headerType}
disabled={triggerOptions.readonly}
iconTooltip={getTriggerDefaultLabel(trigger)}
/>
<WorkflowStepBody>
<Select
dropdownId="workflow-edit-manual-trigger-availability"
@@ -1,5 +1,4 @@
import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState';
import { SidePanelHeader } from '@/command-menu/components/SidePanelHeader';
import { FormRawJsonFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRawJsonFieldInput';
import { Select } from '@/ui/input/components/Select';
import { TextInput } from '@/ui/input/components/TextInput';
@@ -12,10 +11,6 @@ import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowS
import { WorkflowStepFooter } from '@/workflow/workflow-steps/components/WorkflowStepFooter';
import { WEBHOOK_TRIGGER_AUTHENTICATION_OPTIONS } from '@/workflow/workflow-trigger/constants/WebhookTriggerAuthenticationOptions';
import { WEBHOOK_TRIGGER_HTTP_METHOD_OPTIONS } from '@/workflow/workflow-trigger/constants/WebhookTriggerHttpMethodOptions';
import { getTriggerDefaultLabel } from '@/workflow/workflow-trigger/utils/getTriggerDefaultLabel';
import { getTriggerHeaderType } from '@/workflow/workflow-trigger/utils/getTriggerHeaderType';
import { getTriggerIcon } from '@/workflow/workflow-trigger/utils/getTriggerIcon';
import { getTriggerIconColor } from '@/workflow/workflow-trigger/utils/getTriggerIconColor';
import { getWebhookTriggerDefaultSettings } from '@/workflow/workflow-trigger/utils/getWebhookTriggerDefaultSettings';
import { useTheme } from '@emotion/react';
import { isNonEmptyString } from '@sniptt/guards';
@@ -26,7 +21,7 @@ import {
buildOutputSchemaFromValue,
TRIGGER_STEP_ID,
} from 'twenty-shared/workflow';
import { IconCopy, useIcons } from 'twenty-ui/display';
import { IconCopy } from 'twenty-ui/display';
import { useDebouncedCallback } from 'use-debounce';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
@@ -60,7 +55,6 @@ export const WorkflowEditTriggerWebhookForm = ({
const { copyToClipboard } = useCopyToClipboard();
const [errorMessages, setErrorMessages] = useState<FormErrorMessages>({});
const [errorMessagesVisible, setErrorMessagesVisible] = useState(false);
const { getIcon } = useIcons();
const workflowVisualizerWorkflowId = useRecoilComponentValue(
workflowVisualizerWorkflowIdComponentState,
);
@@ -70,11 +64,6 @@ export const WorkflowEditTriggerWebhookForm = ({
setErrorMessagesVisible(true);
};
const headerTitle = trigger.name ?? getTriggerDefaultLabel(trigger);
const headerIcon = getTriggerIcon(trigger);
const headerType = getTriggerHeaderType(trigger);
const webhookUrl = `${REACT_APP_SERVER_BASE_URL}/webhooks/workflows/${currentWorkspace?.id}/${workflowVisualizerWorkflowId}`;
const displayWebhookUrl = webhookUrl.replace(/^(https?:\/\/)?(www\.)?/, '');
@@ -89,27 +78,6 @@ export const WorkflowEditTriggerWebhookForm = ({
return (
<>
<SidePanelHeader
onTitleChange={(newName: string) => {
if (triggerOptions.readonly === true) {
return;
}
triggerOptions.onTriggerUpdate(
{
...trigger,
name: newName,
},
{ computeOutputSchema: false },
);
}}
Icon={getIcon(headerIcon)}
iconColor={getTriggerIconColor({ theme, triggerType: trigger.type })}
initialTitle={headerTitle}
headerType={headerType}
disabled={triggerOptions.readonly}
iconTooltip={getTriggerDefaultLabel(trigger)}
/>
<WorkflowStepBody>
<TextInput
label="Live URL"

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