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:
@ -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[],
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
export enum AgentChatMessageRole {
|
||||
USER = 'user',
|
||||
ASSISTANT = 'assistant',
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
export enum WorkflowAiAgentTabId {
|
||||
SETTINGS = 'settings',
|
||||
TOOLS = 'tools',
|
||||
CHAT = 'chat',
|
||||
}
|
||||
|
||||
export const WORKFLOW_AI_AGENT_TAB_LIST_COMPONENT_ID =
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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),
|
||||
});
|
||||
};
|
||||
@ -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),
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const agentChatInputState = atom<string>({
|
||||
default: '',
|
||||
key: 'agentChatInputState',
|
||||
});
|
||||
@ -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,
|
||||
});
|
||||
@ -0,0 +1,6 @@
|
||||
import { atom } from 'recoil';
|
||||
|
||||
export const agentStreamingMessageState = atom<string>({
|
||||
key: 'agentStreamingMessageState',
|
||||
default: '',
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user