Feat - Agent chat tab (#13061)

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Marie <51697796+ijreilly@users.noreply.github.com>
Co-authored-by: Antoine Moreaux <moreaux.antoine@gmail.com>
Co-authored-by: Raphaël Bosi <71827178+bosiraphael@users.noreply.github.com>
This commit is contained in:
Abdul Rahman
2025-07-08 02:17:41 +05:30
committed by GitHub
parent 29f7b74756
commit 51d02c13bf
40 changed files with 2777 additions and 127 deletions

View File

@ -9,6 +9,7 @@ import {
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';
import { RestLink } from 'apollo-link-rest';
import { createUploadLink } from 'apollo-upload-client';
import { renewToken } from '@/auth/services/AuthService';
@ -21,6 +22,7 @@ import { i18n } from '@lingui/core';
import { GraphQLFormattedError } from 'graphql';
import isEmpty from 'lodash.isempty';
import { getGenericOperationName, isDefined } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { cookieStorage } from '~/utils/cookie-storage';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
import { ApolloManager } from '../types/apolloManager.interface';
@ -67,6 +69,10 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
uri,
});
const restLink = new RestLink({
uri: `${REACT_APP_SERVER_BASE_URL}/rest`,
});
const authLink = setContext(async (_, { headers }) => {
const tokenPair = getTokenPair();
@ -222,8 +228,9 @@ export class ApolloFactory<TCacheShape> implements ApolloManager<TCacheShape> {
...(extraLinks || []),
isDebugMode ? logger : null,
retryLink,
restLink,
httpLink,
].filter(isDefined),
].filter(isDefined) as ApolloLink[],
);
};

View File

@ -5,6 +5,7 @@ import {
OperationVariables,
WatchQueryFetchPolicy,
} from '@apollo/client';
import { Unmasked } from '@apollo/client/masking';
import { isNonEmptyArray } from '@apollo/client/utilities';
import { isNonEmptyString } from '@sniptt/guards';
import { useMemo } from 'react';
@ -54,7 +55,7 @@ type UseFindManyRecordsStateParams<
updateQuery?: (
previousQueryResult: TData,
options: {
fetchMoreResult: TFetchData;
fetchMoreResult: Unmasked<TFetchData>;
variables: TFetchVars;
},
) => TData;

View File

@ -5,6 +5,7 @@ import {
OperationVariables,
WatchQueryFetchPolicy,
} from '@apollo/client';
import { Unmasked } from '@apollo/client/masking';
import { isNonEmptyArray } from '@apollo/client/utilities';
import { isNonEmptyString } from '@sniptt/guards';
import { useRecoilCallback } from 'recoil';
@ -52,7 +53,7 @@ type UseFindManyRecordsStateParams<
updateQuery?: (
previousQueryResult: TData,
options: {
fetchMoreResult: TFetchData;
fetchMoreResult: Unmasked<TFetchData>;
variables: TFetchVars;
},
) => TData;

View File

@ -0,0 +1,32 @@
import { gql } from '@apollo/client';
export const GET_AGENT_CHAT_THREADS = gql`
query GetAgentChatThreads($agentId: String!) {
threads(agentId: $agentId)
@rest(
type: "AgentChatThread"
path: "/agent-chat/threads/{args.agentId}"
) {
id
agentId
createdAt
updatedAt
}
}
`;
export const GET_AGENT_CHAT_MESSAGES = gql`
query GetAgentChatMessages($threadId: String!) {
messages(threadId: $threadId)
@rest(
type: "AgentChatMessage"
path: "/agent-chat/threads/{args.threadId}/messages"
) {
id
threadId
role
content
createdAt
}
}
`;

View File

@ -0,0 +1,105 @@
import { getTokenPair } from '@/apollo/utils/getTokenPair';
import { renewToken } from '@/auth/services/AuthService';
import { AppPath } from '@/types/AppPath';
import { isDefined } from 'twenty-shared/utils';
import { REACT_APP_SERVER_BASE_URL } from '~/config';
import { cookieStorage } from '~/utils/cookie-storage';
const handleTokenRenewal = async () => {
const tokenPair = getTokenPair();
if (!isDefined(tokenPair?.refreshToken?.token)) {
throw new Error('No refresh token available');
}
const newTokens = await renewToken(
`${REACT_APP_SERVER_BASE_URL}/graphql`,
tokenPair,
);
if (!isDefined(newTokens)) {
throw new Error('Token renewal failed');
}
cookieStorage.setItem('tokenPair', JSON.stringify(newTokens));
return newTokens;
};
const createStreamRequest = (
threadId: string,
userMessage: string,
accessToken: string,
) => ({
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
threadId,
userMessage,
}),
});
const handleStreamResponse = async (
response: Response,
onChunk: (chunk: string) => void,
): Promise<string> => {
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let accumulated = '';
if (isDefined(reader)) {
let done = false;
while (!done) {
const { value, done: isDone } = await reader.read();
done = isDone;
if (done) break;
const chunk = decoder.decode(value, { stream: true });
accumulated += chunk;
onChunk(accumulated);
}
}
return accumulated;
};
export const streamChatResponse = async (
threadId: string,
userMessage: string,
onChunk: (chunk: string) => void,
) => {
const tokenPair = getTokenPair();
if (!isDefined(tokenPair?.accessToken?.token)) {
throw new Error('No access token available');
}
const accessToken = tokenPair.accessToken.token;
const response = await fetch(
`${REACT_APP_SERVER_BASE_URL}/rest/agent-chat/stream`,
createStreamRequest(threadId, userMessage, accessToken),
);
if (response.ok) {
return handleStreamResponse(response, onChunk);
}
if (response.status === 401) {
try {
const newTokens = await handleTokenRenewal();
const retryResponse = await fetch(
`${REACT_APP_SERVER_BASE_URL}/rest/agent-chat/stream`,
createStreamRequest(threadId, userMessage, newTokens.accessToken.token),
);
if (retryResponse.ok) {
return handleStreamResponse(retryResponse, onChunk);
}
} catch (renewalError) {
window.location.href = AppPath.SignInUp;
}
throw new Error('Authentication failed');
}
};

View File

@ -0,0 +1,47 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import Skeleton, { SkeletonTheme } from 'react-loading-skeleton';
const StyledSkeletonContainer = styled.div`
display: flex;
flex: 1;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(6)};
padding: ${({ theme }) => theme.spacing(3)};
`;
const StyledMessageBubble = styled.div`
align-items: center;
display: flex;
gap: ${({ theme }) => theme.spacing(3)};
`;
const StyledMessageSkeleton = styled.div`
width: 100%;
`;
const NUMBER_OF_SKELETONS = 6;
export const AIChatSkeletonLoader = () => {
const theme = useTheme();
return (
<SkeletonTheme
baseColor={theme.background.tertiary}
highlightColor={theme.background.transparent.lighter}
borderRadius={4}
>
<StyledSkeletonContainer>
{Array.from({ length: NUMBER_OF_SKELETONS }).map((_, index) => (
<StyledMessageBubble key={index}>
<Skeleton width={24} height={24} borderRadius={4} />
<StyledMessageSkeleton>
<Skeleton height={20} borderRadius={8} />
</StyledMessageSkeleton>
</StyledMessageBubble>
))}
</StyledSkeletonContainer>
</SkeletonTheme>
);
};

View File

@ -0,0 +1,254 @@
import { TextArea } from '@/ui/input/components/TextArea';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import React from 'react';
import { Avatar, IconDotsVertical, IconSparkles } from 'twenty-ui/display';
import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton';
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
import { t } from '@lingui/core/macro';
import { Button } from 'twenty-ui/input';
import { beautifyPastDateRelativeToNow } from '~/utils/date-utils';
import { useAgentChat } from '../hooks/useAgentChat';
import { AIChatSkeletonLoader } from './AIChatSkeletonLoader';
const StyledContainer = styled.div`
background: ${({ theme }) => theme.background.primary};
height: calc(100% - 154px);
`;
const StyledEmptyState = styled.div`
display: flex;
flex-direction: column;
flex: 1;
gap: ${({ theme }) => theme.spacing(2)};
align-items: center;
justify-content: center;
height: 100%;
`;
const StyledSparkleIcon = styled.div`
align-items: center;
background: ${({ theme }) => theme.background.transparent.blue};
border-radius: ${({ theme }) => theme.border.radius.sm};
display: flex;
height: 40px;
justify-content: center;
margin-bottom: ${({ theme }) => theme.spacing(2)};
width: 40px;
`;
const StyledTitle = styled.div`
font-size: ${({ theme }) => theme.font.size.lg};
font-weight: 600;
`;
const StyledDescription = styled.div`
color: ${({ theme }) => theme.font.color.secondary};
text-align: center;
max-width: 85%;
font-size: ${({ theme }) => theme.font.size.md};
`;
const StyledInputArea = styled.div`
align-items: flex-end;
bottom: 0;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(2)};
position: absolute;
width: calc(100% - 24px);
padding: ${({ theme }) => theme.spacing(3)};
background: ${({ theme }) => theme.background.primary};
`;
const StyledScrollWrapper = styled(ScrollWrapper)`
display: flex;
flex: 1;
flex-direction: column;
gap: ${({ theme }) => theme.spacing(5)};
overflow-y: auto;
padding: ${({ theme }) => theme.spacing(3)};
width: calc(100% - 24px);
`;
const StyledMessageBubble = styled.div<{ isUser?: boolean }>`
display: flex;
flex-direction: column;
align-items: flex-start;
position: relative;
width: 100%;
&:hover .message-footer {
opacity: 1;
pointer-events: auto;
}
`;
const StyledMessageRow = styled.div`
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(3)};
width: 100%;
`;
const StyledMessageText = styled.div<{ isUser?: boolean }>`
background: ${({ theme, isUser }) =>
isUser ? theme.background.secondary : theme.background.transparent};
border-radius: ${({ theme }) => theme.border.radius.md};
padding: ${({ theme, isUser }) => (isUser ? theme.spacing(1, 2) : 0)};
border: ${({ isUser, theme }) =>
!isUser ? 'none' : `1px solid ${theme.border.color.light}`};
color: ${({ theme, isUser }) =>
isUser ? theme.font.color.light : theme.font.color.primary};
font-weight: ${({ isUser }) => (isUser ? 500 : 400)};
width: fit-content;
white-space: pre-line;
`;
const StyledMessageFooter = styled.div`
align-items: center;
color: ${({ theme }) => theme.font.color.secondary};
display: flex;
font-size: ${({ theme }) => theme.font.size.sm};
justify-content: space-between;
margin-top: ${({ theme }) => theme.spacing(1)};
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease-in-out;
width: 100%;
`;
const StyledAvatarContainer = styled.div<{ isUser?: boolean }>`
align-items: center;
background: ${({ theme, isUser }) =>
isUser
? theme.background.transparent.light
: theme.background.transparent.blue};
display: flex;
justify-content: center;
height: 24px;
min-width: 24px;
border-radius: ${({ theme }) => theme.border.radius.sm};
padding: 1px;
`;
const StyledDotsIconContainer = styled.div`
align-items: center;
border: ${({ theme }) => `1px solid ${theme.border.color.light}`};
border-radius: ${({ theme }) => theme.border.radius.md};
display: flex;
justify-content: center;
padding-inline: ${({ theme }) => theme.spacing(1)};
`;
const StyledDotsIcon = styled(IconDotsVertical)`
color: ${({ theme }) => theme.font.color.light};
transform: rotate(90deg);
`;
const StyledMessageContainer = styled.div`
width: 100%;
`;
type AIChatTabProps = {
agentId: string;
};
export const AIChatTab: React.FC<AIChatTabProps> = ({ agentId }) => {
const theme = useTheme();
const {
messages,
isLoading,
handleSendMessage,
input,
handleInputChange,
agentStreamingMessage,
} = useAgentChat(agentId);
return (
<StyledContainer>
{messages.length !== 0 && (
<StyledScrollWrapper componentInstanceId={agentId}>
{messages.map((msg) => (
<StyledMessageBubble
key={msg.id}
isUser={msg.role === AgentChatMessageRole.USER}
>
<StyledMessageRow>
{msg.role === AgentChatMessageRole.ASSISTANT && (
<StyledAvatarContainer>
<Avatar
size="sm"
placeholder="AI"
Icon={IconSparkles}
iconColor={theme.color.blue}
/>
</StyledAvatarContainer>
)}
{msg.role === AgentChatMessageRole.USER && (
<StyledAvatarContainer isUser>
<Avatar size="sm" placeholder="U" type="rounded" />
</StyledAvatarContainer>
)}
<StyledMessageContainer>
<StyledMessageText
isUser={msg.role === AgentChatMessageRole.USER}
>
{msg.role === AgentChatMessageRole.ASSISTANT && !msg.content
? agentStreamingMessage || (
<StyledDotsIconContainer>
<StyledDotsIcon size={theme.icon.size.xl} />
</StyledDotsIconContainer>
)
: msg.content}
</StyledMessageText>
{msg.content && (
<StyledMessageFooter className="message-footer">
<span>
{beautifyPastDateRelativeToNow(msg.createdAt)}
</span>
<LightCopyIconButton copyText={msg.content} />
</StyledMessageFooter>
)}
</StyledMessageContainer>
</StyledMessageRow>
</StyledMessageBubble>
))}
</StyledScrollWrapper>
)}
{messages.length === 0 && !isLoading && (
<StyledEmptyState>
<StyledSparkleIcon>
<IconSparkles size={theme.icon.size.lg} color={theme.color.blue} />
</StyledSparkleIcon>
<StyledTitle>{t`Chat`}</StyledTitle>
<StyledDescription>
{t`Start a conversation with your AI agent to get workflow insights, task assistance, and process guidance`}
</StyledDescription>
</StyledEmptyState>
)}
{isLoading && messages.length === 0 && <AIChatSkeletonLoader />}
<StyledInputArea>
<TextArea
textAreaId={`${agentId}-chat-input`}
placeholder={t`Enter a question...`}
value={input}
onChange={handleInputChange}
/>
<Button
variant="primary"
accent="blue"
size="small"
hotkeys={input && !isLoading ? ['⏎'] : undefined}
disabled={!input || isLoading}
title={t`Send`}
onClick={handleSendMessage}
/>
</StyledInputArea>
</StyledContainer>
);
};

View File

@ -6,12 +6,18 @@ import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/
import { WorkflowAiAgentAction } from '@/workflow/types/Workflow';
import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody';
import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader';
import { AIChatTab } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/components/AIChatTab';
import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader';
import { WorkflowVariablePicker } from '@/workflow/workflow-variables/components/WorkflowVariablePicker';
import { BaseOutputSchema } from '@/workflow/workflow-variables/types/StepOutputSchema';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { IconSettings, IconTool, useIcons } from 'twenty-ui/display';
import {
IconMessage,
IconSettings,
IconTool,
useIcons,
} from 'twenty-ui/display';
import { RightDrawerSkeletonLoader } from '~/loading/components/RightDrawerSkeletonLoader';
import {
WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID,
@ -89,6 +95,7 @@ export const WorkflowEditActionAiAgent = ({
Icon: IconSettings,
},
{ id: WorkflowAiAgentTabId.TOOLS, title: t`Tools`, Icon: IconTool },
{ id: WorkflowAiAgentTabId.CHAT, title: t`Chat`, Icon: IconMessage },
];
return loading ? (
@ -100,70 +107,76 @@ export const WorkflowEditActionAiAgent = ({
behaveAsLinks={false}
componentInstanceId={WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID}
/>
<WorkflowStepHeader
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}
/>
<WorkflowStepBody>
{activeTabId === WorkflowAiAgentTabId.SETTINGS && (
<>
<div>
<Select
dropdownId="select-model"
label={t`AI Model`}
options={modelOptions}
value={formValues.modelId}
onChange={(value) => handleFieldChange('modelId', value)}
disabled={actionOptions.readonly || noModelsAvailable}
emptyOption={{
label: t`No AI models available`,
value: '',
}}
/>
{noModelsAvailable && (
<StyledErrorMessage>
{t`You haven't configured any model provider. Please set up an API Key in your instance's admin panel or as an environment variable.`}
</StyledErrorMessage>
)}
</div>
<FormTextFieldInput
key={`prompt-${formValues.modelId ? action.id : 'empty'}`}
label={t`Instructions for AI`}
placeholder={t`Describe what you want the AI to do...`}
readonly={actionOptions.readonly}
defaultValue={formValues.prompt}
onChange={(value) => handleFieldChange('prompt', value)}
VariablePicker={WorkflowVariablePicker}
multiline
/>
<WorkflowOutputSchemaBuilder
fields={outputFields}
onChange={handleOutputSchemaChange}
readonly={actionOptions.readonly}
/>
</>
)}
{activeTabId === WorkflowAiAgentTabId.TOOLS && (
<Select
dropdownId="select-agent-role"
label={t`Assign Role`}
options={[{ label: t`No role`, value: '' }, ...rolesOptions]}
value={selectedRole || ''}
onChange={handleRoleChange}
{activeTabId === WorkflowAiAgentTabId.CHAT ? (
<AIChatTab agentId={agentId} />
) : (
<>
<WorkflowStepHeader
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}
/>
)}
</WorkflowStepBody>
<WorkflowStepBody>
{activeTabId === WorkflowAiAgentTabId.SETTINGS && (
<>
<div>
<Select
dropdownId="select-model"
label={t`AI Model`}
options={modelOptions}
value={formValues.modelId}
onChange={(value) => handleFieldChange('modelId', value)}
disabled={actionOptions.readonly || noModelsAvailable}
emptyOption={{
label: t`No AI models available`,
value: '',
}}
/>
{noModelsAvailable && (
<StyledErrorMessage>
{t`You haven't configured any model provider. Please set up an API Key in your instance's admin panel or as an environment variable.`}
</StyledErrorMessage>
)}
</div>
<FormTextFieldInput
key={`prompt-${formValues.modelId ? action.id : 'empty'}`}
label={t`Instructions for AI`}
placeholder={t`Describe what you want the AI to do...`}
readonly={actionOptions.readonly}
defaultValue={formValues.prompt}
onChange={(value) => handleFieldChange('prompt', value)}
VariablePicker={WorkflowVariablePicker}
multiline
/>
<WorkflowOutputSchemaBuilder
fields={outputFields}
onChange={handleOutputSchemaChange}
readonly={actionOptions.readonly}
/>
</>
)}
{activeTabId === WorkflowAiAgentTabId.TOOLS && (
<Select
dropdownId="select-agent-role"
label={t`Assign Role`}
options={[{ label: t`No role`, value: '' }, ...rolesOptions]}
value={selectedRole || ''}
onChange={handleRoleChange}
disabled={actionOptions.readonly}
/>
)}
</WorkflowStepBody>
</>
)}
</>
);
};

View File

@ -0,0 +1,4 @@
export enum AgentChatMessageRole {
USER = 'user',
ASSISTANT = 'assistant',
}

View File

@ -1,6 +1,7 @@
export enum WorkflowAiAgentTabId {
SETTINGS = 'settings',
TOOLS = 'tools',
CHAT = 'chat',
}
export const WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID =

View File

@ -0,0 +1,140 @@
import { InputHotkeyScope } from '@/ui/input/types/InputHotkeyScope';
import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys';
import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2';
import { useCallback, useState } from 'react';
import { useRecoilState } from 'recoil';
import { Key } from 'ts-key-enum';
import { useScrollWrapperElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperElement';
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
import { v4 } from 'uuid';
import { streamChatResponse } from '../api/streamChatResponse';
import { agentChatInputState } from '../states/agentChatInputState';
import { agentChatMessagesComponentState } from '../states/agentChatMessagesComponentState';
import { agentStreamingMessageState } from '../states/agentStreamingMessageState';
import { AgentChatMessage, useAgentChatMessages } from './useAgentChatMessages';
import { useAgentChatThreads } from './useAgentChatThreads';
interface OptimisticMessage extends AgentChatMessage {
isPending: boolean;
}
export const useAgentChat = (agentId: string) => {
const [agentChatMessages, setAgentChatMessages] = useRecoilComponentStateV2(
agentChatMessagesComponentState,
agentId,
);
const [agentChatInput, setAgentChatInput] =
useRecoilState(agentChatInputState);
const [agentStreamingMessage, setAgentStreamingMessage] = useRecoilState(
agentStreamingMessageState,
);
const [isStreaming, setIsStreaming] = useState(false);
const { scrollWrapperHTMLElement } = useScrollWrapperElement(agentId);
const scrollToBottom = useCallback(() => {
scrollWrapperHTMLElement?.scroll({
top: scrollWrapperHTMLElement.scrollHeight,
behavior: 'smooth',
});
}, [scrollWrapperHTMLElement]);
const { data: { threads = [] } = {}, loading: threadsLoading } =
useAgentChatThreads(agentId);
const currentThreadId = threads[0]?.id;
const { loading: messagesLoading, refetch: refetchMessages } =
useAgentChatMessages(currentThreadId);
const isLoading = messagesLoading || threadsLoading || isStreaming;
const createOptimisticMessages = (content: string): AgentChatMessage[] => {
const optimisticUserMessage: OptimisticMessage = {
id: v4(),
threadId: currentThreadId,
role: AgentChatMessageRole.USER,
content,
createdAt: new Date().toISOString(),
isPending: true,
};
const optimisticAiMessage: OptimisticMessage = {
id: v4(),
threadId: currentThreadId,
role: AgentChatMessageRole.ASSISTANT,
content: '',
createdAt: new Date().toISOString(),
isPending: true,
};
return [optimisticUserMessage, optimisticAiMessage];
};
const streamAgentResponse = async (content: string) => {
if (!currentThreadId) {
return '';
}
setIsStreaming(true);
await streamChatResponse(currentThreadId, content, (chunk) => {
setAgentStreamingMessage(chunk);
scrollToBottom();
});
setIsStreaming(false);
};
const sendChatMessage = async (content: string) => {
const optimisticMessages = createOptimisticMessages(content);
setAgentChatMessages((prevMessages) => [
...prevMessages,
...optimisticMessages,
]);
setTimeout(scrollToBottom, 100);
await streamAgentResponse(content);
const { data } = await refetchMessages();
setAgentChatMessages(data?.messages);
setAgentStreamingMessage('');
scrollToBottom();
};
const handleSendMessage = async () => {
if (!agentChatInput.trim() || isLoading) {
return;
}
const content = agentChatInput.trim();
setAgentChatInput('');
await sendChatMessage(content);
};
useScopedHotkeys(
[Key.Enter],
(event) => {
if (!event.ctrlKey && !event.metaKey) {
event.preventDefault();
handleSendMessage();
}
},
InputHotkeyScope.TextInput,
[agentChatInput, isLoading],
);
return {
handleInputChange: (value: string) => setAgentChatInput(value),
messages: agentChatMessages,
input: agentChatInput,
handleSendMessage,
isLoading,
agentStreamingMessage,
};
};

View File

@ -0,0 +1,19 @@
import { AgentChatMessageRole } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/constants/agent-chat-message-role';
import { useQuery } from '@apollo/client';
import { isDefined } from 'twenty-shared/utils';
import { GET_AGENT_CHAT_MESSAGES } from '../api/agent-chat-apollo.api';
export type AgentChatMessage = {
id: string;
threadId: string;
role: AgentChatMessageRole;
content: string;
createdAt: string;
};
export const useAgentChatMessages = (threadId: string) => {
return useQuery<{ messages: AgentChatMessage[] }>(GET_AGENT_CHAT_MESSAGES, {
variables: { threadId },
skip: !isDefined(threadId),
});
};

View File

@ -0,0 +1,17 @@
import { useQuery } from '@apollo/client';
import { isDefined } from 'twenty-shared/utils';
import { GET_AGENT_CHAT_THREADS } from '../api/agent-chat-apollo.api';
export interface AgentChatThread {
id: string;
agentId: string;
createdAt: string;
updatedAt: string;
}
export const useAgentChatThreads = (agentId: string) => {
return useQuery<{ threads: AgentChatThread[] }>(GET_AGENT_CHAT_THREADS, {
variables: { agentId },
skip: !isDefined(agentId),
});
};

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const agentChatInputState = atom<string>({
default: '',
key: 'agentChatInputState',
});

View File

@ -0,0 +1,14 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2';
import { AgentChatMessage } from '@/workflow/workflow-steps/workflow-actions/ai-agent-action/hooks/useAgentChatMessages';
export const AgentChatMessagesComponentInstanceContext =
createComponentInstanceContext();
export const agentChatMessagesComponentState = createComponentStateV2<
AgentChatMessage[]
>({
key: 'agentChatMessagesComponentState',
defaultValue: [],
componentInstanceContext: AgentChatMessagesComponentInstanceContext,
});

View File

@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const agentStreamingMessageState = atom<string>({
key: 'agentStreamingMessageState',
default: '',
});

View File

@ -0,0 +1,238 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { getFieldIcon } from '../getFieldIcon';
describe('getFieldIcon', () => {
const UNSUPPORTED_FIELD_TYPES = [
'string',
'number',
'boolean',
'object',
'array',
'unknown',
FieldMetadataType.DATE_TIME,
FieldMetadataType.EMAILS,
FieldMetadataType.PHONES,
FieldMetadataType.LINKS,
FieldMetadataType.CURRENCY,
FieldMetadataType.SELECT,
FieldMetadataType.MULTI_SELECT,
FieldMetadataType.RELATION,
FieldMetadataType.UUID,
FieldMetadataType.RAW_JSON,
FieldMetadataType.FULL_NAME,
FieldMetadataType.ADDRESS,
FieldMetadataType.ARRAY,
] as const;
type UnsupportedFieldType = (typeof UNSUPPORTED_FIELD_TYPES)[number];
describe('FieldMetadataType field types', () => {
it('should return IconAbc for TEXT field type', () => {
const result = getFieldIcon(FieldMetadataType.TEXT);
expect(result).toBe('IconAbc');
});
it('should return IconText for NUMBER field type', () => {
const result = getFieldIcon(FieldMetadataType.NUMBER);
expect(result).toBe('IconText');
});
it('should return IconCheckbox for BOOLEAN field type', () => {
const result = getFieldIcon(FieldMetadataType.BOOLEAN);
expect(result).toBe('IconCheckbox');
});
it('should return IconCalendarEvent for DATE field type', () => {
const result = getFieldIcon(FieldMetadataType.DATE);
expect(result).toBe('IconCalendarEvent');
});
});
describe('basic InputSchemaPropertyType field types', () => {
it('should return IconQuestionMark for string type', () => {
const result = getFieldIcon('string');
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for number type', () => {
const result = getFieldIcon('number');
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for boolean type', () => {
const result = getFieldIcon('boolean');
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for object type', () => {
const result = getFieldIcon('object');
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for array type', () => {
const result = getFieldIcon('array');
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for unknown type', () => {
const result = getFieldIcon('unknown');
expect(result).toBe('IconQuestionMark');
});
});
describe('other FieldMetadataType values', () => {
it('should return IconQuestionMark for DATE_TIME field type', () => {
const result = getFieldIcon(FieldMetadataType.DATE_TIME);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for EMAILS field type', () => {
const result = getFieldIcon(FieldMetadataType.EMAILS);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for PHONES field type', () => {
const result = getFieldIcon(FieldMetadataType.PHONES);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for LINKS field type', () => {
const result = getFieldIcon(FieldMetadataType.LINKS);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for CURRENCY field type', () => {
const result = getFieldIcon(FieldMetadataType.CURRENCY);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for SELECT field type', () => {
const result = getFieldIcon(FieldMetadataType.SELECT);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for MULTI_SELECT field type', () => {
const result = getFieldIcon(FieldMetadataType.MULTI_SELECT);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for RELATION field type', () => {
const result = getFieldIcon(FieldMetadataType.RELATION);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for UUID field type', () => {
const result = getFieldIcon(FieldMetadataType.UUID);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for RAW_JSON field type', () => {
const result = getFieldIcon(FieldMetadataType.RAW_JSON);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for FULL_NAME field type', () => {
const result = getFieldIcon(FieldMetadataType.FULL_NAME);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for ADDRESS field type', () => {
const result = getFieldIcon(FieldMetadataType.ADDRESS);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for ARRAY field type', () => {
const result = getFieldIcon(FieldMetadataType.ARRAY);
expect(result).toBe('IconQuestionMark');
});
});
describe('edge cases', () => {
it('should return IconQuestionMark for undefined field type', () => {
const result = getFieldIcon(undefined);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for null field type', () => {
const result = getFieldIcon(null as any);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for empty string field type', () => {
const result = getFieldIcon('' as any);
expect(result).toBe('IconQuestionMark');
});
it('should return IconQuestionMark for invalid field type', () => {
const result = getFieldIcon('INVALID_TYPE' as any);
expect(result).toBe('IconQuestionMark');
});
});
describe('icon mapping consistency', () => {
it('should return consistent icons for the same field type', () => {
const fieldType = FieldMetadataType.TEXT;
const result1 = getFieldIcon(fieldType);
const result2 = getFieldIcon(fieldType);
expect(result1).toBe(result2);
expect(result1).toBe('IconAbc');
});
it('should have unique icons for different supported field types', () => {
const textIcon = getFieldIcon(FieldMetadataType.TEXT);
const numberIcon = getFieldIcon(FieldMetadataType.NUMBER);
const booleanIcon = getFieldIcon(FieldMetadataType.BOOLEAN);
const dateIcon = getFieldIcon(FieldMetadataType.DATE);
const icons = [textIcon, numberIcon, booleanIcon, dateIcon];
const uniqueIcons = new Set(icons);
expect(uniqueIcons.size).toBe(4);
expect(icons).toEqual([
'IconAbc',
'IconText',
'IconCheckbox',
'IconCalendarEvent',
]);
});
it('should return IconQuestionMark for all unsupported field types', () => {
const unsupportedTypes = UNSUPPORTED_FIELD_TYPES;
unsupportedTypes.forEach((fieldType) => {
const result = getFieldIcon(fieldType as UnsupportedFieldType);
expect(result).toBe('IconQuestionMark');
});
});
});
describe('function behavior', () => {
it('should be a pure function with no side effects', () => {
const fieldType = FieldMetadataType.TEXT;
const result1 = getFieldIcon(fieldType);
const result2 = getFieldIcon(fieldType);
const result3 = getFieldIcon(fieldType);
expect(result1).toBe(result2);
expect(result2).toBe(result3);
expect(result1).toBe('IconAbc');
});
it('should handle all possible InputSchemaPropertyType values', () => {
const allPossibleTypes = [
...UNSUPPORTED_FIELD_TYPES,
FieldMetadataType.TEXT,
FieldMetadataType.NUMBER,
FieldMetadataType.BOOLEAN,
FieldMetadataType.DATE,
] as const;
allPossibleTypes.forEach((fieldType) => {
const result = getFieldIcon(fieldType as UnsupportedFieldType);
expect(typeof result).toBe('string');
expect(result.length).toBeGreaterThan(0);
});
});
});
});

View File

@ -0,0 +1,217 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { v4 } from 'uuid';
import { WorkflowFormFieldType } from '../../types/WorkflowFormFieldType';
import { getDefaultFormFieldSettings } from '../getDefaultFormFieldSettings';
jest.mock('uuid', () => ({
v4: jest.fn(() => 'test-uuid-123'),
}));
describe('getDefaultFormFieldSettings', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('FieldMetadataType.TEXT', () => {
it('should return correct default settings for TEXT field type', () => {
const result = getDefaultFormFieldSettings(FieldMetadataType.TEXT);
expect(result).toEqual({
id: 'test-uuid-123',
name: 'text',
label: 'Text',
placeholder: 'Enter your text',
});
});
});
describe('FieldMetadataType.NUMBER', () => {
it('should return correct default settings for NUMBER field type', () => {
const result = getDefaultFormFieldSettings(FieldMetadataType.NUMBER);
expect(result).toEqual({
id: 'test-uuid-123',
name: 'number',
label: 'Number',
placeholder: '1000',
});
});
});
describe('FieldMetadataType.DATE', () => {
it('should return correct default settings for DATE field type', () => {
const result = getDefaultFormFieldSettings(FieldMetadataType.DATE);
expect(result).toEqual({
id: 'test-uuid-123',
name: 'date',
label: 'Date',
placeholder: 'mm/dd/yyyy',
});
});
});
describe('RECORD type', () => {
it('should return correct default settings for RECORD field type', () => {
const result = getDefaultFormFieldSettings('RECORD');
expect(result).toEqual({
id: 'test-uuid-123',
name: 'record',
label: 'Record',
placeholder: 'Select a Company',
settings: {
objectName: 'company',
},
});
});
});
describe('UUID generation', () => {
it('should generate unique UUID for each call', () => {
getDefaultFormFieldSettings(FieldMetadataType.TEXT);
getDefaultFormFieldSettings(FieldMetadataType.NUMBER);
expect(v4).toHaveBeenCalledTimes(2);
});
});
describe('return value structure', () => {
it('should return object with required properties for TEXT type', () => {
const result = getDefaultFormFieldSettings(FieldMetadataType.TEXT);
expect(result).toHaveProperty('id');
expect(result).toHaveProperty('name');
expect(result).toHaveProperty('label');
expect(result).toHaveProperty('placeholder');
expect(result).not.toHaveProperty('settings');
});
it('should return object with required properties for NUMBER type', () => {
const result = getDefaultFormFieldSettings(FieldMetadataType.NUMBER);
expect(result).toHaveProperty('id');
expect(result).toHaveProperty('name');
expect(result).toHaveProperty('label');
expect(result).toHaveProperty('placeholder');
expect(result).not.toHaveProperty('settings');
});
it('should return object with required properties for DATE type', () => {
const result = getDefaultFormFieldSettings(FieldMetadataType.DATE);
expect(result).toHaveProperty('id');
expect(result).toHaveProperty('name');
expect(result).toHaveProperty('label');
expect(result).toHaveProperty('placeholder');
expect(result).not.toHaveProperty('settings');
});
it('should return object with required properties for RECORD type', () => {
const result = getDefaultFormFieldSettings('RECORD');
expect(result).toHaveProperty('id');
expect(result).toHaveProperty('name');
expect(result).toHaveProperty('label');
expect(result).toHaveProperty('placeholder');
expect(result).toHaveProperty('settings');
expect(result.settings).toHaveProperty('objectName');
});
});
describe('field type specific values', () => {
it('should have correct name values for each field type', () => {
const textResult = getDefaultFormFieldSettings(FieldMetadataType.TEXT);
const numberResult = getDefaultFormFieldSettings(
FieldMetadataType.NUMBER,
);
const dateResult = getDefaultFormFieldSettings(FieldMetadataType.DATE);
const recordResult = getDefaultFormFieldSettings('RECORD');
expect(textResult.name).toBe('text');
expect(numberResult.name).toBe('number');
expect(dateResult.name).toBe('date');
expect(recordResult.name).toBe('record');
});
it('should have correct label values for each field type', () => {
const textResult = getDefaultFormFieldSettings(FieldMetadataType.TEXT);
const numberResult = getDefaultFormFieldSettings(
FieldMetadataType.NUMBER,
);
const dateResult = getDefaultFormFieldSettings(FieldMetadataType.DATE);
const recordResult = getDefaultFormFieldSettings('RECORD');
expect(textResult.label).toBe('Text');
expect(numberResult.label).toBe('Number');
expect(dateResult.label).toBe('Date');
expect(recordResult.label).toBe('Record');
});
it('should have correct placeholder values for each field type', () => {
const textResult = getDefaultFormFieldSettings(FieldMetadataType.TEXT);
const numberResult = getDefaultFormFieldSettings(
FieldMetadataType.NUMBER,
);
const dateResult = getDefaultFormFieldSettings(FieldMetadataType.DATE);
const recordResult = getDefaultFormFieldSettings('RECORD');
expect(textResult.placeholder).toBe('Enter your text');
expect(numberResult.placeholder).toBe('1000');
expect(dateResult.placeholder).toBe('mm/dd/yyyy');
expect(recordResult.placeholder).toBe('Select a Company');
});
});
describe('RECORD type specific settings', () => {
it('should have correct settings object for RECORD type', () => {
const result = getDefaultFormFieldSettings('RECORD');
expect(result.settings).toEqual({
objectName: 'company',
});
});
it('should have objectName set to company for RECORD type', () => {
const result = getDefaultFormFieldSettings('RECORD');
expect(result.settings?.objectName).toBe('company');
});
});
describe('consistency', () => {
it('should return consistent results for the same field type', () => {
const result1 = getDefaultFormFieldSettings(FieldMetadataType.TEXT);
const result2 = getDefaultFormFieldSettings(FieldMetadataType.TEXT);
expect(result1).toEqual(result2);
});
it('should return different results for different field types', () => {
const textResult = getDefaultFormFieldSettings(FieldMetadataType.TEXT);
const numberResult = getDefaultFormFieldSettings(
FieldMetadataType.NUMBER,
);
expect(textResult).not.toEqual(numberResult);
expect(textResult.name).not.toBe(numberResult.name);
expect(textResult.label).not.toBe(numberResult.label);
expect(textResult.placeholder).not.toBe(numberResult.placeholder);
});
});
describe('type safety', () => {
it('should accept all valid WorkflowFormFieldType values', () => {
const validTypes: WorkflowFormFieldType[] = [
FieldMetadataType.TEXT,
FieldMetadataType.NUMBER,
FieldMetadataType.DATE,
'RECORD',
];
validTypes.forEach((type) => {
expect(() => getDefaultFormFieldSettings(type)).not.toThrow();
});
});
});
});

View File

@ -0,0 +1,111 @@
import { renderHook } from '@testing-library/react';
import { useFilteredOtherActions } from '../useFilteredOtherActions';
jest.mock('@/workspace/hooks/useIsFeatureEnabled', () => ({
useIsFeatureEnabled: jest.fn(),
}));
jest.mock('../../constants/OtherActions', () => ({
OTHER_ACTIONS: [
{ type: 'CODE', icon: 'IconCode', label: 'Code' },
{ type: 'HTTP_REQUEST', icon: 'IconHttp', label: 'HTTP Request' },
{ type: 'SEND_EMAIL', icon: 'IconMail', label: 'Send Email' },
{ type: 'AI_AGENT', icon: 'IconBrain', label: 'AI Agent' },
{ type: 'FORM', icon: 'IconForm', label: 'Form' },
],
}));
describe('useFilteredOtherActions', () => {
const mockUseIsFeatureEnabled = jest.mocked(
jest.requireMock('@/workspace/hooks/useIsFeatureEnabled')
.useIsFeatureEnabled,
);
beforeEach(() => {
jest.clearAllMocks();
});
it('should return all actions when AI is enabled', () => {
mockUseIsFeatureEnabled.mockReturnValue(true);
const { result } = renderHook(() => useFilteredOtherActions());
expect(result.current).toHaveLength(5);
expect(result.current).toEqual([
{ type: 'CODE', icon: 'IconCode', label: 'Code' },
{ type: 'HTTP_REQUEST', icon: 'IconHttp', label: 'HTTP Request' },
{ type: 'SEND_EMAIL', icon: 'IconMail', label: 'Send Email' },
{ type: 'AI_AGENT', icon: 'IconBrain', label: 'AI Agent' },
{ type: 'FORM', icon: 'IconForm', label: 'Form' },
]);
});
it('should filter out AI_AGENT when AI is disabled', () => {
mockUseIsFeatureEnabled.mockReturnValue(false);
const { result } = renderHook(() => useFilteredOtherActions());
expect(result.current).toHaveLength(4);
expect(result.current).toEqual([
{ type: 'CODE', icon: 'IconCode', label: 'Code' },
{ type: 'HTTP_REQUEST', icon: 'IconHttp', label: 'HTTP Request' },
{ type: 'SEND_EMAIL', icon: 'IconMail', label: 'Send Email' },
{ type: 'FORM', icon: 'IconForm', label: 'Form' },
]);
expect(
result.current.find((action) => action.type === 'AI_AGENT'),
).toBeUndefined();
});
it('should call useIsFeatureEnabled with correct feature flag', () => {
mockUseIsFeatureEnabled.mockReturnValue(true);
renderHook(() => useFilteredOtherActions());
expect(mockUseIsFeatureEnabled).toHaveBeenCalledWith('IS_AI_ENABLED');
expect(mockUseIsFeatureEnabled).toHaveBeenCalledTimes(1);
});
it('should handle feature flag hook returning undefined', () => {
mockUseIsFeatureEnabled.mockReturnValue(undefined);
const { result } = renderHook(() => useFilteredOtherActions());
expect(result.current).toHaveLength(4);
expect(
result.current.find((action) => action.type === 'AI_AGENT'),
).toBeUndefined();
});
it('should handle feature flag hook returning null', () => {
mockUseIsFeatureEnabled.mockReturnValue(null);
const { result } = renderHook(() => useFilteredOtherActions());
expect(result.current).toHaveLength(4);
expect(
result.current.find((action) => action.type === 'AI_AGENT'),
).toBeUndefined();
});
it('should handle feature flag hook returning false string', () => {
mockUseIsFeatureEnabled.mockReturnValue('false');
const { result } = renderHook(() => useFilteredOtherActions());
expect(result.current).toHaveLength(5);
expect(
result.current.find((action) => action.type === 'AI_AGENT'),
).toBeDefined();
});
it('should handle feature flag hook throwing error', () => {
mockUseIsFeatureEnabled.mockImplementation(() => {
throw new Error('Feature flag error');
});
expect(() => {
renderHook(() => useFilteredOtherActions());
}).toThrow('Feature flag error');
});
});

View File

@ -0,0 +1,57 @@
import { parseHttpJsonBodyWithoutVariablesOrThrow } from '../parseHttpJsonBodyWithoutVariablesOrThrow';
describe('parseHttpJsonBodyWithoutVariablesOrThrow', () => {
it('should parse valid JSON without variables', () => {
const jsonString = '{"name": "John", "age": 30, "active": true}';
const result = parseHttpJsonBodyWithoutVariablesOrThrow(jsonString);
expect(result).toEqual({
name: 'John',
age: 30,
active: true,
});
});
it('should handle nested objects with variables', () => {
const jsonString =
'{"user": {"name": "{{user.name}}", "email": "{{user.email}}"}}';
const result = parseHttpJsonBodyWithoutVariablesOrThrow(jsonString);
expect(result).toEqual({
user: {
name: 'null',
email: 'null',
},
});
});
it('should handle arrays with variables', () => {
const jsonString = '{"items": ["{{item1}}", "{{item2}}", "static"]}';
const result = parseHttpJsonBodyWithoutVariablesOrThrow(jsonString);
expect(result).toEqual({
items: ['null', 'null', 'static'],
});
});
it('should throw error for invalid JSON', () => {
const invalidJson = '{"name": "John", "age": 30,}';
expect(() => {
parseHttpJsonBodyWithoutVariablesOrThrow(invalidJson);
}).toThrow();
});
it('should handle empty object', () => {
const jsonString = '{}';
const result = parseHttpJsonBodyWithoutVariablesOrThrow(jsonString);
expect(result).toEqual({});
});
it('should handle empty string', () => {
expect(() => {
parseHttpJsonBodyWithoutVariablesOrThrow('');
}).toThrow();
});
});

View File

@ -0,0 +1,111 @@
import { HttpRequestBody } from '../../constants/HttpRequest';
import { shouldDisplayRawJsonByDefault } from '../shouldDisplayRawJsonByDefault';
describe('shouldDisplayRawJsonByDefault', () => {
describe('when defaultValue is undefined', () => {
it('should return false', () => {
expect(shouldDisplayRawJsonByDefault(undefined)).toBe(false);
});
});
describe('when defaultValue is a string', () => {
describe('when string contains valid JSON with only string values', () => {
it('should return false for simple object', () => {
const defaultValue = '{"key1": "value1", "key2": "value2"}';
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(false);
});
it('should return true for nested object with only string values', () => {
const defaultValue = '{"key1": "value1", "nested": {"key2": "value2"}}';
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
it('should return false for array with only string values', () => {
const defaultValue = '["value1", "value2", "value3"]';
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(false);
});
it('should return true for empty object', () => {
const defaultValue = '{}';
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
it('should return true for empty array', () => {
const defaultValue = '[]';
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
});
describe('when string is not valid JSON', () => {
it('should return true for invalid JSON string', () => {
const defaultValue = 'invalid json string';
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
it('should return true for malformed JSON', () => {
const defaultValue = '{"key1": "value1", "key2":}';
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
it('should return true for plain text', () => {
const defaultValue = 'This is just plain text';
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
});
});
describe('when defaultValue is an HttpRequestBody object', () => {
describe('when object has non-string values', () => {
it('should return true for object with number values', () => {
const defaultValue: HttpRequestBody = { key1: 'value1', key2: 123 };
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
it('should return true for object with boolean values', () => {
const defaultValue: HttpRequestBody = { key1: 'value1', key2: true };
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
it('should return true for object with null values', () => {
const defaultValue: HttpRequestBody = { key1: 'value1', key2: null };
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
it('should return true for object with array values', () => {
const defaultValue: HttpRequestBody = {
key1: 'value1',
key2: [1, 'two', true, null],
};
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
it('should return true for object with mixed types', () => {
const defaultValue: HttpRequestBody = {
key1: 'value1',
key2: 123,
key3: true,
key4: null,
key5: ['array', 'values'],
};
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
});
});
describe('edge cases', () => {
it('should handle complex nested structures', () => {
const defaultValue = JSON.stringify({
level1: {
level2: {
stringValue: 'test',
numberValue: 42,
booleanValue: true,
nullValue: null,
arrayValue: [1, 'two', false],
},
},
});
expect(shouldDisplayRawJsonByDefault(defaultValue)).toBe(true);
});
});
});

View File

@ -0,0 +1,316 @@
import { WorkflowActionType } from '@/workflow/types/Workflow';
import { Theme } from '@emotion/react';
import { COLOR, GRAY_SCALE } from 'twenty-ui/theme';
import { getActionIconColorOrThrow } from '../getActionIconColorOrThrow';
const mockTheme: Theme = {
color: {
orange: COLOR.orange,
blue: COLOR.blue,
pink: COLOR.pink,
},
font: {
color: {
tertiary: GRAY_SCALE.gray40,
},
},
} as Theme;
describe('getActionIconColorOrThrow', () => {
describe('action types that return orange color', () => {
it('should return orange color for CODE action type', () => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'CODE',
});
expect(result).toBe(mockTheme.color.orange);
});
it('should return orange color for HTTP_REQUEST action type', () => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'HTTP_REQUEST',
});
expect(result).toBe(mockTheme.color.orange);
});
});
describe('action types that return tertiary font color', () => {
const recordActionTypes: WorkflowActionType[] = [
'CREATE_RECORD',
'UPDATE_RECORD',
'DELETE_RECORD',
'FIND_RECORDS',
'FORM',
];
recordActionTypes.forEach((actionType) => {
it(`should return tertiary font color for ${actionType} action type`, () => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType,
});
expect(result).toBe(mockTheme.font.color.tertiary);
});
});
});
describe('action types that return blue color', () => {
it('should return blue color for SEND_EMAIL action type', () => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'SEND_EMAIL',
});
expect(result).toBe(mockTheme.color.blue);
});
});
describe('action types that return pink color', () => {
it('should return pink color for AI_AGENT action type', () => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'AI_AGENT',
});
expect(result).toBe(mockTheme.color.pink);
});
});
describe('FILTER action type', () => {
it('should throw an error for FILTER action type', () => {
expect(() => {
getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'FILTER',
});
}).toThrow("The Filter action isn't meant to be displayed as a node.");
});
});
describe('theme object handling', () => {
it('should use the provided theme colors correctly', () => {
const customTheme: Theme = {
color: {
orange: COLOR.red,
blue: COLOR.purple,
pink: COLOR.turquoise,
},
font: {
color: {
tertiary: GRAY_SCALE.gray50,
},
},
} as Theme;
expect(
getActionIconColorOrThrow({
theme: customTheme,
actionType: 'CODE',
}),
).toBe(COLOR.red);
expect(
getActionIconColorOrThrow({
theme: customTheme,
actionType: 'SEND_EMAIL',
}),
).toBe(COLOR.purple);
expect(
getActionIconColorOrThrow({
theme: customTheme,
actionType: 'AI_AGENT',
}),
).toBe(COLOR.turquoise);
expect(
getActionIconColorOrThrow({
theme: customTheme,
actionType: 'CREATE_RECORD',
}),
).toBe(GRAY_SCALE.gray50);
});
});
describe('type safety and exhaustive checking', () => {
it('should handle all valid action types without throwing unreachable errors', () => {
const validActionTypes: WorkflowActionType[] = [
'CODE',
'HTTP_REQUEST',
'CREATE_RECORD',
'UPDATE_RECORD',
'DELETE_RECORD',
'FIND_RECORDS',
'FORM',
'SEND_EMAIL',
'AI_AGENT',
];
validActionTypes.forEach((actionType) => {
expect(() => {
getActionIconColorOrThrow({
theme: mockTheme,
actionType,
});
}).not.toThrow();
});
});
it('should return consistent color values for the same action type', () => {
const actionType: WorkflowActionType = 'CODE';
const result1 = getActionIconColorOrThrow({
theme: mockTheme,
actionType,
});
const result2 = getActionIconColorOrThrow({
theme: mockTheme,
actionType,
});
expect(result1).toBe(result2);
expect(result1).toBe(mockTheme.color.orange);
});
});
describe('color grouping logic', () => {
it('should group CODE and HTTP_REQUEST actions with orange color', () => {
const orangeActions: WorkflowActionType[] = ['CODE', 'HTTP_REQUEST'];
orangeActions.forEach((actionType) => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType,
});
expect(result).toBe(mockTheme.color.orange);
});
});
it('should group record-related actions with tertiary font color', () => {
const recordActions: WorkflowActionType[] = [
'CREATE_RECORD',
'UPDATE_RECORD',
'DELETE_RECORD',
'FIND_RECORDS',
'FORM',
];
recordActions.forEach((actionType) => {
const result = getActionIconColorOrThrow({
theme: mockTheme,
actionType,
});
expect(result).toBe(mockTheme.font.color.tertiary);
});
});
it('should have unique colors for different action categories', () => {
const orangeResult = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'CODE',
});
const tertiaryResult = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'CREATE_RECORD',
});
const blueResult = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'SEND_EMAIL',
});
const pinkResult = getActionIconColorOrThrow({
theme: mockTheme,
actionType: 'AI_AGENT',
});
const colors = [orangeResult, tertiaryResult, blueResult, pinkResult];
const uniqueColors = new Set(colors);
expect(uniqueColors.size).toBe(4);
});
});
describe('error handling edge cases', () => {
it('should handle theme object with missing properties gracefully', () => {
const incompleteTheme: Theme = {
color: {
orange: COLOR.orange,
},
font: {
color: {
tertiary: GRAY_SCALE.gray40,
},
},
} as Theme;
expect(
getActionIconColorOrThrow({
theme: incompleteTheme,
actionType: 'CODE',
}),
).toBe(COLOR.orange);
expect(
getActionIconColorOrThrow({
theme: incompleteTheme,
actionType: 'CREATE_RECORD',
}),
).toBe(GRAY_SCALE.gray40);
});
it('should return undefined when blue color is missing for SEND_EMAIL action', () => {
const themeWithoutBlue: Theme = {
color: {
orange: COLOR.orange,
pink: COLOR.pink,
},
font: {
color: {
tertiary: GRAY_SCALE.gray40,
},
},
} as Theme;
const result = getActionIconColorOrThrow({
theme: themeWithoutBlue,
actionType: 'SEND_EMAIL',
});
expect(result).toBeUndefined();
});
it('should return undefined when pink color is missing for AI_AGENT action', () => {
const themeWithoutPink: Theme = {
color: {
orange: COLOR.orange,
blue: COLOR.blue,
},
font: {
color: {
tertiary: GRAY_SCALE.gray40,
},
},
} as Theme;
const result = getActionIconColorOrThrow({
theme: themeWithoutPink,
actionType: 'AI_AGENT',
});
expect(result).toBeUndefined();
});
it('should handle null or undefined theme gracefully', () => {
expect(() => {
getActionIconColorOrThrow({
theme: null as unknown as Theme,
actionType: 'CODE',
});
}).toThrow();
});
});
});

View File

@ -0,0 +1,183 @@
import { WorkflowTrigger } from '@/workflow/types/Workflow';
import { DatabaseTriggerDefaultLabel } from '@/workflow/workflow-trigger/constants/DatabaseTriggerDefaultLabel';
import { getTriggerDefaultLabel } from '../getTriggerLabel';
describe('getTriggerDefaultLabel', () => {
describe('DATABASE_EVENT triggers', () => {
it('returns "Record is Created" for created event', () => {
const trigger: WorkflowTrigger = {
type: 'DATABASE_EVENT',
name: 'Company Created',
settings: {
eventName: 'company.created',
outputSchema: {},
},
};
const result = getTriggerDefaultLabel(trigger);
expect(result).toBe(DatabaseTriggerDefaultLabel.RECORD_IS_CREATED);
});
it('returns "Record is Updated" for updated event', () => {
const trigger: WorkflowTrigger = {
type: 'DATABASE_EVENT',
name: 'Company Updated',
settings: {
eventName: 'company.updated',
outputSchema: {},
},
};
const result = getTriggerDefaultLabel(trigger);
expect(result).toBe(DatabaseTriggerDefaultLabel.RECORD_IS_UPDATED);
});
it('returns "Record is Deleted" for deleted event', () => {
const trigger: WorkflowTrigger = {
type: 'DATABASE_EVENT',
name: 'Company Deleted',
settings: {
eventName: 'company.deleted',
outputSchema: {},
},
};
const result = getTriggerDefaultLabel(trigger);
expect(result).toBe(DatabaseTriggerDefaultLabel.RECORD_IS_DELETED);
});
it('works with different object types', () => {
const trigger: WorkflowTrigger = {
type: 'DATABASE_EVENT',
name: 'Person Created',
settings: {
eventName: 'person.created',
outputSchema: {},
},
};
const result = getTriggerDefaultLabel(trigger);
expect(result).toBe(DatabaseTriggerDefaultLabel.RECORD_IS_CREATED);
});
it('throws error for unknown database event', () => {
const trigger: WorkflowTrigger = {
type: 'DATABASE_EVENT',
name: 'Unknown Event',
settings: {
eventName: 'company.unknown',
outputSchema: {},
},
};
expect(() => getTriggerDefaultLabel(trigger)).toThrow(
'Unknown trigger event',
);
});
});
describe('MANUAL triggers', () => {
it('returns "Launch manually" for manual trigger', () => {
const trigger: WorkflowTrigger = {
type: 'MANUAL',
name: 'Manual Trigger',
settings: {
objectType: 'company',
outputSchema: {},
icon: 'IconHandMove',
},
};
const result = getTriggerDefaultLabel(trigger);
expect(result).toBe('Launch manually');
});
});
describe('CRON triggers', () => {
it('returns "On a Schedule" for cron trigger', () => {
const trigger: WorkflowTrigger = {
type: 'CRON',
name: 'Scheduled Trigger',
settings: {
type: 'DAYS',
schedule: {
day: 1,
hour: 9,
minute: 0,
},
outputSchema: {},
},
};
const result = getTriggerDefaultLabel(trigger);
expect(result).toBe('On a Schedule');
});
});
describe('WEBHOOK triggers', () => {
it('returns "Webhook" for webhook trigger with GET method', () => {
const trigger: WorkflowTrigger = {
type: 'WEBHOOK',
name: 'Webhook Trigger',
settings: {
httpMethod: 'GET',
authentication: null,
outputSchema: {},
},
};
const result = getTriggerDefaultLabel(trigger);
expect(result).toBe('Webhook');
});
it('returns "Webhook" for webhook trigger with POST method', () => {
const trigger: WorkflowTrigger = {
type: 'WEBHOOK',
name: 'Webhook Trigger',
settings: {
httpMethod: 'POST',
expectedBody: {
message: 'Workflow was started',
},
authentication: null,
outputSchema: {
message: {
icon: 'IconVariable',
isLeaf: true,
label: 'message',
type: 'string',
value: 'Workflow was started',
},
},
},
};
const result = getTriggerDefaultLabel(trigger);
expect(result).toBe('Webhook');
});
});
describe('error cases', () => {
it('throws error for unknown trigger type', () => {
const trigger = {
type: 'UNKNOWN_TYPE',
name: 'Unknown Trigger',
settings: {
outputSchema: {},
},
} as unknown as WorkflowTrigger;
expect(() => getTriggerDefaultLabel(trigger)).toThrow(
'Unknown trigger type',
);
});
});
});