Redesign AI chat and add pre-existing prompts. (#17787)
Redesigned AI-chat based on the following provided design. <p align="center"> <img src="https://github.com/user-attachments/assets/f10ebbd2-9ee9-402f-b246-6e8f8cedbd53" width="225" /> </p> --------- Co-authored-by: Félix Malfait <felix@twenty.com>
This commit is contained in:
@@ -1,52 +1,19 @@
|
||||
import { useTheme } from '@emotion/react';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { IconSparkles } from 'twenty-ui/display';
|
||||
|
||||
import { AIChatSuggestedPrompts } from '@/ai/components/suggested-prompts/AIChatSuggestedPrompts';
|
||||
|
||||
const StyledEmptyState = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: flex-end;
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
const StyledSparkleIcon = styled.div`
|
||||
align-items: center;
|
||||
background: ${({ theme }) => theme.background.transparent.blue};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
padding: ${({ theme }) => theme.spacing(2.5)};
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: ${({ theme }) => theme.spacing(2)};
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.div`
|
||||
font-size: ${({ theme }) => theme.font.size.lg};
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
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};
|
||||
`;
|
||||
|
||||
export const AIChatEmptyState = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<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>
|
||||
<AIChatSuggestedPrompts />
|
||||
</StyledEmptyState>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { TextArea } from '@/ui/input/components/TextArea';
|
||||
import styled from '@emotion/styled';
|
||||
import { IconHistory, IconMessageCirclePlus } from 'twenty-ui/display';
|
||||
import { IconHistory } from 'twenty-ui/display';
|
||||
import { IconButton } from 'twenty-ui/input';
|
||||
|
||||
import { DropZone } from '@/activities/files/components/DropZone';
|
||||
import { AgentChatFileUploadButton } from '@/ai/components/internal/AgentChatFileUploadButton';
|
||||
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
|
||||
import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
|
||||
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
|
||||
import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper';
|
||||
|
||||
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
|
||||
import { AIChatEmptyState } from '@/ai/components/AIChatEmptyState';
|
||||
import { AIChatMessage } from '@/ai/components/AIChatMessage';
|
||||
import { AIChatStandaloneError } from '@/ai/components/AIChatStandaloneError';
|
||||
@@ -17,6 +16,7 @@ import { AIChatContextUsageButton } from '@/ai/components/internal/AIChatContext
|
||||
import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoader';
|
||||
import { AgentChatContextPreview } from '@/ai/components/internal/AgentChatContextPreview';
|
||||
import { SendMessageButton } from '@/ai/components/internal/SendMessageButton';
|
||||
import { AgentMessageRole } from '@/ai/constants/AgentMessageRole';
|
||||
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
|
||||
import { AI_CHAT_SCROLL_WRAPPER_ID } from '@/ai/constants/AiChatScrollWrapperId';
|
||||
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
|
||||
@@ -25,7 +25,6 @@ import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
|
||||
const StyledContainer = styled.div<{ isDraggingFile: boolean }>`
|
||||
background: ${({ theme }) => theme.background.primary};
|
||||
@@ -46,6 +45,46 @@ const StyledInputArea = styled.div`
|
||||
background: ${({ theme }) => theme.background.primary};
|
||||
`;
|
||||
|
||||
const StyledInputBox = styled.div`
|
||||
background-color: ${({ theme }) => theme.background.transparent.lighter};
|
||||
border: 1px solid ${({ theme }) => theme.border.color.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
min-height: 140px;
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus-within {
|
||||
border-color: ${({ theme }) => theme.color.blue};
|
||||
box-shadow: 0px 0px 0px 3px ${({ theme }) => theme.color.transparent.blue2};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledTextAreaWrapper = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const StyledChatTextArea = styled(TextArea)`
|
||||
&& {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
&&:focus {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledScrollWrapper = styled(ScrollWrapper)`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
@@ -59,7 +98,8 @@ const StyledScrollWrapper = styled(ScrollWrapper)`
|
||||
const StyledButtonsContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: ${({ theme }) => theme.spacing(2)};
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
export const AIChatTab = () => {
|
||||
@@ -72,7 +112,6 @@ export const AIChatTab = () => {
|
||||
useRecoilState(agentChatInputState);
|
||||
|
||||
const { uploadFiles } = useAIChatFileUpload();
|
||||
const { createChatThread } = useCreateNewAIChatThread();
|
||||
const { navigateCommandMenu } = useCommandMenu();
|
||||
|
||||
return (
|
||||
@@ -115,7 +154,9 @@ export const AIChatTab = () => {
|
||||
)}
|
||||
</StyledScrollWrapper>
|
||||
)}
|
||||
{messages.length === 0 && !error && <AIChatEmptyState />}
|
||||
{messages.length === 0 && !error && !isLoading && (
|
||||
<AIChatEmptyState />
|
||||
)}
|
||||
{messages.length === 0 && error && !isLoading && (
|
||||
<AIChatStandaloneError error={error} />
|
||||
)}
|
||||
@@ -123,37 +164,36 @@ export const AIChatTab = () => {
|
||||
|
||||
<StyledInputArea>
|
||||
<AgentChatContextPreview />
|
||||
<TextArea
|
||||
textAreaId={AI_CHAT_INPUT_ID}
|
||||
placeholder={t`Enter a question...`}
|
||||
value={agentChatInput}
|
||||
onChange={(value) => setAgentChatInput(value)}
|
||||
minRows={1}
|
||||
maxRows={20}
|
||||
/>
|
||||
<StyledButtonsContainer>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
Icon={IconHistory}
|
||||
onClick={() =>
|
||||
navigateCommandMenu({
|
||||
page: CommandMenuPages.ViewPreviousAIChats,
|
||||
pageTitle: t`View Previous AI Chats`,
|
||||
pageIcon: IconHistory,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="small"
|
||||
Icon={IconMessageCirclePlus}
|
||||
onClick={() => createChatThread()}
|
||||
/>
|
||||
<AgentChatFileUploadButton />
|
||||
<AIChatContextUsageButton />
|
||||
<SendMessageButton />
|
||||
</StyledButtonsContainer>
|
||||
<StyledInputBox>
|
||||
<StyledTextAreaWrapper>
|
||||
<StyledChatTextArea
|
||||
textAreaId={AI_CHAT_INPUT_ID}
|
||||
placeholder={t`Ask, search or make anything...`}
|
||||
value={agentChatInput}
|
||||
onChange={(value) => setAgentChatInput(value)}
|
||||
minRows={3}
|
||||
maxRows={20}
|
||||
/>
|
||||
</StyledTextAreaWrapper>
|
||||
<StyledButtonsContainer>
|
||||
<AIChatContextUsageButton />
|
||||
<IconButton
|
||||
Icon={IconHistory}
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
onClick={() =>
|
||||
navigateCommandMenu({
|
||||
page: CommandMenuPages.ViewPreviousAIChats,
|
||||
pageTitle: t`View Previous AI Chats`,
|
||||
pageIcon: IconHistory,
|
||||
})
|
||||
}
|
||||
ariaLabel={t`View Previous AI Chats`}
|
||||
/>
|
||||
<AgentChatFileUploadButton />
|
||||
<SendMessageButton />
|
||||
</StyledButtonsContainer>
|
||||
</StyledInputBox>
|
||||
</StyledInputArea>
|
||||
</>
|
||||
)}
|
||||
|
||||
+2
-7
@@ -19,14 +19,11 @@ const StyledContainer = styled.div`
|
||||
|
||||
const StyledTrigger = styled.div<{ hasUsage: boolean }>`
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 1px solid ${({ theme }) => theme.background.transparent.medium};
|
||||
border-radius: ${({ theme }) => theme.border.radius.sm};
|
||||
cursor: ${({ hasUsage }) => (hasUsage ? 'pointer' : 'default')};
|
||||
display: flex;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
height: 24px;
|
||||
padding: 0 ${({ theme }) => theme.spacing(2)};
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
transition: background 0.1s ease;
|
||||
|
||||
&:hover {
|
||||
@@ -137,7 +134,6 @@ export const AIChatContextUsageButton = () => {
|
||||
<StyledContainer>
|
||||
<StyledTrigger hasUsage={false}>
|
||||
<ContextUsageProgressRing percentage={0} />
|
||||
<StyledPercentage>0%</StyledPercentage>
|
||||
</StyledTrigger>
|
||||
</StyledContainer>
|
||||
);
|
||||
@@ -160,7 +156,6 @@ export const AIChatContextUsageButton = () => {
|
||||
>
|
||||
<StyledTrigger hasUsage={true}>
|
||||
<ContextUsageProgressRing percentage={percentage} />
|
||||
<StyledPercentage>{formattedPercentage}%</StyledPercentage>
|
||||
</StyledTrigger>
|
||||
|
||||
{isHovered && (
|
||||
|
||||
+7
-5
@@ -1,10 +1,11 @@
|
||||
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
|
||||
import { agentChatSelectedFilesState } from '@/ai/states/agentChatSelectedFilesState';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import React, { useRef } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { IconPaperclip } from 'twenty-ui/display';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { IconPlus } from 'twenty-ui/display';
|
||||
import { IconButton } from 'twenty-ui/input';
|
||||
|
||||
const StyledFileUploadContainer = styled.div`
|
||||
display: flex;
|
||||
@@ -44,13 +45,14 @@ export const AgentChatFileUploadButton = () => {
|
||||
onChange={handleFileInputChange}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
<IconButton
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
Icon={IconPaperclip}
|
||||
Icon={IconPlus}
|
||||
ariaLabel={t`Attach files`}
|
||||
/>
|
||||
</StyledFileUploadContainer>
|
||||
);
|
||||
|
||||
+5
-1
@@ -13,7 +13,11 @@ const StyledSvg = styled.svg`
|
||||
|
||||
const StyledBackgroundCircle = styled.circle`
|
||||
fill: none;
|
||||
stroke: ${({ theme }) => theme.background.quaternary};
|
||||
stroke: color-mix(
|
||||
in srgb,
|
||||
${({ theme }) => theme.border.color.strong} 50%,
|
||||
${({ theme }) => theme.background.quaternary} 50%
|
||||
);
|
||||
`;
|
||||
|
||||
const StyledProgressCircle = styled.circle`
|
||||
|
||||
@@ -2,10 +2,10 @@ import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
|
||||
import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow';
|
||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import { Button } from 'twenty-ui/input';
|
||||
import { IconArrowUp } from 'twenty-ui/display';
|
||||
import { RoundedIconButton } from 'twenty-ui/input';
|
||||
|
||||
export const SendMessageButton = () => {
|
||||
const agentChatInput = useRecoilValue(agentChatInputState);
|
||||
@@ -27,14 +27,10 @@ export const SendMessageButton = () => {
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
hotkeys={agentChatInput && !isLoading ? ['⏎'] : undefined}
|
||||
<RoundedIconButton
|
||||
Icon={IconArrowUp}
|
||||
onClick={() => handleSendMessage()}
|
||||
disabled={!agentChatInput || isLoading}
|
||||
variant="primary"
|
||||
accent="blue"
|
||||
size="small"
|
||||
title={t`Send`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { LightButton } from 'twenty-ui/input';
|
||||
|
||||
import {
|
||||
DEFAULT_SUGGESTED_PROMPTS,
|
||||
type SuggestedPrompt,
|
||||
} from '@/ai/components/suggested-prompts/default-suggested-prompts';
|
||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
|
||||
const StyledContainer = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${({ theme }) => theme.spacing(1)};
|
||||
padding: ${({ theme }) => theme.spacing(3)};
|
||||
`;
|
||||
|
||||
const StyledTitle = styled.div`
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-size: ${({ theme }) => theme.font.size.sm};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
margin-bottom: ${({ theme }) => theme.spacing(1)};
|
||||
`;
|
||||
|
||||
const StyledSuggestedPromptButton = styled(LightButton)`
|
||||
width: 100%;
|
||||
`;
|
||||
|
||||
const pickRandom = <T,>(items: T[]): T =>
|
||||
items[Math.floor(Math.random() * items.length)];
|
||||
|
||||
export const AIChatSuggestedPrompts = () => {
|
||||
const { t: resolveMessage } = useLingui();
|
||||
const setAgentChatInput = useSetRecoilState(agentChatInputState);
|
||||
|
||||
const handleClick = (prompt: SuggestedPrompt) => {
|
||||
const picked = pickRandom(prompt.prefillPrompts);
|
||||
|
||||
setAgentChatInput(resolveMessage(picked));
|
||||
};
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledTitle>{t`What can I help you with?`}</StyledTitle>
|
||||
{DEFAULT_SUGGESTED_PROMPTS.map((prompt) => (
|
||||
<StyledSuggestedPromptButton
|
||||
key={prompt.id}
|
||||
Icon={prompt.Icon}
|
||||
title={resolveMessage(prompt.label)}
|
||||
accent="secondary"
|
||||
onClick={() => handleClick(prompt)}
|
||||
/>
|
||||
))}
|
||||
</StyledContainer>
|
||||
);
|
||||
};
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import {
|
||||
type IconComponent,
|
||||
IconLayoutDashboard,
|
||||
IconPlus,
|
||||
IconSettingsAutomation,
|
||||
} from 'twenty-ui/display';
|
||||
|
||||
export type SuggestedPrompt = {
|
||||
id: string;
|
||||
label: MessageDescriptor;
|
||||
Icon: IconComponent;
|
||||
prefillPrompts: MessageDescriptor[];
|
||||
};
|
||||
|
||||
export const DEFAULT_SUGGESTED_PROMPTS: SuggestedPrompt[] = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: msg`Create a dashboard`,
|
||||
Icon: IconLayoutDashboard,
|
||||
prefillPrompts: [
|
||||
msg`Create a dashboard with a chart of deal value by pipeline stage (New, Meeting, Proposal, Negotiation, Closed Won/Lost) for the current quarter, and a table of my top 10 open opportunities with amount, stage and expected close date.`,
|
||||
msg`Build a dashboard that shows: (1) total pipeline value by stage for the last 3 months, (2) count of deals won vs lost per month, (3) average deal size. Use our standard pipeline stages.`,
|
||||
msg`I need a dashboard for lead conversion: number of new leads by source this month, how many moved to opportunity, and conversion rate by source. Include a simple table and a bar chart.`,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'workflow',
|
||||
label: msg`Create a workflow`,
|
||||
Icon: IconSettingsAutomation,
|
||||
prefillPrompts: [
|
||||
msg`When a deal's stage changes to Closed Won, create a task assigned to the deal owner, due 7 days after the close date, with title "Post-sale check-in" and the company name in the description.`,
|
||||
msg`When a new lead is created with source "Website", assign it to the sales rep whose territory (by region/country) matches the lead's address; if no match, assign to the team lead.`,
|
||||
msg`When any deal with amount over $100,000 has its stage or amount updated, send a notification to the sales channel with the deal name, company, new stage, amount and owner.`,
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'record',
|
||||
label: msg`Create a record`,
|
||||
Icon: IconPlus,
|
||||
prefillPrompts: [
|
||||
msg`Add a new company we're in touch with (e.g. name, website, industry). Details: `,
|
||||
msg`Create a new contact and link them to a company. Details: `,
|
||||
msg`Log a new deal (company, amount, stage, expected close). Details: `,
|
||||
],
|
||||
},
|
||||
];
|
||||
+8
-16
@@ -1,16 +1,17 @@
|
||||
import { useCommandMenuHistory } from '@/command-menu/hooks/useCommandMenuHistory';
|
||||
import { useOpenAskAIPageInCommandMenu } from '@/command-menu/hooks/useOpenAskAIPageInCommandMenu';
|
||||
import { commandMenuNavigationStackState } from '@/command-menu/states/commandMenuNavigationStackState';
|
||||
import { commandMenuPageState } from '@/command-menu/states/commandMenuPageState';
|
||||
import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
|
||||
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
|
||||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { IconHandMove, IconSparkles } from 'twenty-ui/display';
|
||||
import { IconEdit, IconSparkles } from 'twenty-ui/display';
|
||||
import { IconButton } from 'twenty-ui/input';
|
||||
import { useIsMobile } from 'twenty-ui/utilities';
|
||||
import { FeatureFlagKey } from '~/generated/graphql';
|
||||
|
||||
import { useCreateNewAIChatThread } from '@/ai/hooks/useCreateNewAIChatThread';
|
||||
|
||||
const StyledIconButton = styled(IconButton)`
|
||||
color: ${({ theme }) => theme.font.color.secondary};
|
||||
`;
|
||||
@@ -19,12 +20,8 @@ export const CommandMenuTopBarRightCornerIcon = () => {
|
||||
const isMobile = useIsMobile();
|
||||
const isAiEnabled = useIsFeatureEnabled(FeatureFlagKey.IS_AI_ENABLED);
|
||||
const commandMenuPage = useRecoilValue(commandMenuPageState);
|
||||
const commandMenuNavigationStack = useRecoilValue(
|
||||
commandMenuNavigationStackState,
|
||||
);
|
||||
|
||||
const { goBackFromCommandMenu } = useCommandMenuHistory();
|
||||
const { openAskAIPage } = useOpenAskAIPageInCommandMenu();
|
||||
const { createChatThread } = useCreateNewAIChatThread();
|
||||
|
||||
if (isMobile || !isAiEnabled) {
|
||||
return null;
|
||||
@@ -35,8 +32,6 @@ export const CommandMenuTopBarRightCornerIcon = () => {
|
||||
CommandMenuPages.ViewPreviousAIChats,
|
||||
].includes(commandMenuPage);
|
||||
|
||||
const canGoBack = commandMenuNavigationStack.length > 1;
|
||||
|
||||
if (!isOnAskAIPage) {
|
||||
return (
|
||||
<StyledIconButton
|
||||
@@ -48,16 +43,13 @@ export const CommandMenuTopBarRightCornerIcon = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (!canGoBack) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledIconButton
|
||||
Icon={IconHandMove}
|
||||
Icon={IconEdit}
|
||||
size="small"
|
||||
variant="tertiary"
|
||||
onClick={goBackFromCommandMenu}
|
||||
onClick={() => createChatThread()}
|
||||
ariaLabel={t`New conversation`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
|
||||
|
||||
type ClickHouseFilterResult = {
|
||||
whereClause: string;
|
||||
params: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FilterOperator =
|
||||
| 'eq'
|
||||
| 'neq'
|
||||
| 'gt'
|
||||
| 'gte'
|
||||
| 'lt'
|
||||
| 'lte'
|
||||
| 'in'
|
||||
| 'is'
|
||||
| 'like'
|
||||
| 'ilike'
|
||||
| 'startsWith'
|
||||
| 'endsWith'
|
||||
| 'contains';
|
||||
|
||||
const getClickHouseType = (value: unknown): string => {
|
||||
if (typeof value === 'string') {
|
||||
return 'String';
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return Number.isInteger(value) ? 'Int64' : 'Float64';
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return 'Bool';
|
||||
}
|
||||
|
||||
return 'String';
|
||||
};
|
||||
|
||||
const buildOperatorCondition = (
|
||||
fieldName: string,
|
||||
operator: FilterOperator,
|
||||
paramName: string,
|
||||
paramType: string,
|
||||
): string => {
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
return `"${fieldName}" = {${paramName}:${paramType}}`;
|
||||
case 'neq':
|
||||
return `"${fieldName}" != {${paramName}:${paramType}}`;
|
||||
case 'gt':
|
||||
return `"${fieldName}" > {${paramName}:${paramType}}`;
|
||||
case 'gte':
|
||||
return `"${fieldName}" >= {${paramName}:${paramType}}`;
|
||||
case 'lt':
|
||||
return `"${fieldName}" < {${paramName}:${paramType}}`;
|
||||
case 'lte':
|
||||
return `"${fieldName}" <= {${paramName}:${paramType}}`;
|
||||
case 'in':
|
||||
return `"${fieldName}" IN {${paramName}:Array(${paramType})}`;
|
||||
case 'is':
|
||||
return `"${fieldName}" IS NULL`;
|
||||
case 'like':
|
||||
return `"${fieldName}" LIKE {${paramName}:${paramType}}`;
|
||||
case 'ilike':
|
||||
return `lower("${fieldName}") LIKE lower({${paramName}:${paramType}})`;
|
||||
case 'startsWith':
|
||||
return `"${fieldName}" LIKE concat({${paramName}:${paramType}}, '%')`;
|
||||
case 'endsWith':
|
||||
return `"${fieldName}" LIKE concat('%', {${paramName}:${paramType}})`;
|
||||
case 'contains':
|
||||
return `"${fieldName}" LIKE concat('%', {${paramName}:${paramType}}, '%')`;
|
||||
default:
|
||||
return `"${fieldName}" = {${paramName}:${paramType}}`;
|
||||
}
|
||||
};
|
||||
|
||||
const parseFilterValue = (
|
||||
fieldName: string,
|
||||
filterValue: unknown,
|
||||
paramIndex: number,
|
||||
): { conditions: string[]; params: Record<string, unknown> } => {
|
||||
const conditions: string[] = [];
|
||||
const params: Record<string, unknown> = {};
|
||||
|
||||
if (!isDefined(filterValue) || typeof filterValue !== 'object') {
|
||||
return { conditions, params };
|
||||
}
|
||||
|
||||
const filterObj = filterValue as Record<string, unknown>;
|
||||
|
||||
for (const [operator, value] of Object.entries(filterObj)) {
|
||||
if (!isDefined(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const paramName = `${fieldName}_${paramIndex}_${operator}`;
|
||||
|
||||
if (operator === 'is') {
|
||||
if (value === 'NULL') {
|
||||
conditions.push(`"${fieldName}" IS NULL`);
|
||||
} else if (value === 'NOT_NULL') {
|
||||
conditions.push(`"${fieldName}" IS NOT NULL`);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const paramType = getClickHouseType(value);
|
||||
|
||||
conditions.push(
|
||||
buildOperatorCondition(
|
||||
fieldName,
|
||||
operator as FilterOperator,
|
||||
paramName,
|
||||
paramType,
|
||||
),
|
||||
);
|
||||
params[paramName] = value;
|
||||
}
|
||||
|
||||
return { conditions, params };
|
||||
};
|
||||
|
||||
export const parseClickHouseFilter = (
|
||||
filter: ObjectRecordFilter | undefined,
|
||||
): ClickHouseFilterResult => {
|
||||
if (!isDefined(filter) || Object.keys(filter).length === 0) {
|
||||
return { whereClause: '', params: {} };
|
||||
}
|
||||
|
||||
const allConditions: string[] = [];
|
||||
const allParams: Record<string, unknown> = {};
|
||||
let paramIndex = 0;
|
||||
|
||||
// Handle 'and' operator
|
||||
if ('and' in filter && Array.isArray(filter.and)) {
|
||||
const andConditions: string[] = [];
|
||||
|
||||
for (const subFilter of filter.and) {
|
||||
const { whereClause, params } = parseClickHouseFilter(
|
||||
subFilter as ObjectRecordFilter,
|
||||
);
|
||||
|
||||
if (whereClause) {
|
||||
andConditions.push(`(${whereClause})`);
|
||||
Object.assign(allParams, params);
|
||||
}
|
||||
}
|
||||
|
||||
if (andConditions.length > 0) {
|
||||
allConditions.push(andConditions.join(' AND '));
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 'or' operator
|
||||
if ('or' in filter && Array.isArray(filter.or)) {
|
||||
const orConditions: string[] = [];
|
||||
|
||||
for (const subFilter of filter.or) {
|
||||
const { whereClause, params } = parseClickHouseFilter(
|
||||
subFilter as ObjectRecordFilter,
|
||||
);
|
||||
|
||||
if (whereClause) {
|
||||
orConditions.push(`(${whereClause})`);
|
||||
Object.assign(allParams, params);
|
||||
}
|
||||
}
|
||||
|
||||
if (orConditions.length > 0) {
|
||||
allConditions.push(`(${orConditions.join(' OR ')})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 'not' operator
|
||||
if ('not' in filter && isDefined(filter.not)) {
|
||||
const { whereClause, params } = parseClickHouseFilter(
|
||||
filter.not as ObjectRecordFilter,
|
||||
);
|
||||
|
||||
if (whereClause) {
|
||||
allConditions.push(`NOT (${whereClause})`);
|
||||
Object.assign(allParams, params);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle field-level filters
|
||||
for (const [fieldName, filterValue] of Object.entries(filter)) {
|
||||
if (['and', 'or', 'not'].includes(fieldName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { conditions, params } = parseFilterValue(
|
||||
fieldName,
|
||||
filterValue,
|
||||
paramIndex++,
|
||||
);
|
||||
|
||||
allConditions.push(...conditions);
|
||||
Object.assign(allParams, params);
|
||||
}
|
||||
|
||||
return {
|
||||
whereClause: allConditions.join(' AND '),
|
||||
params: allParams,
|
||||
};
|
||||
};
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
export const parseClickHouseOrderBy = (
|
||||
orderBy: Array<Record<string, string>> | undefined,
|
||||
): string => {
|
||||
if (!isDefined(orderBy) || orderBy.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const orderClauses: string[] = [];
|
||||
|
||||
for (const orderItem of orderBy) {
|
||||
for (const [fieldName, direction] of Object.entries(orderItem)) {
|
||||
const normalizedDirection = direction
|
||||
.toUpperCase()
|
||||
.replace('NULLS_FIRST', 'NULLS FIRST')
|
||||
.replace('NULLS_LAST', 'NULLS LAST')
|
||||
.replace('ASC_NULLS_FIRST', 'ASC NULLS FIRST')
|
||||
.replace('ASC_NULLS_LAST', 'ASC NULLS LAST')
|
||||
.replace('DESC_NULLS_FIRST', 'DESC NULLS FIRST')
|
||||
.replace('DESC_NULLS_LAST', 'DESC NULLS LAST');
|
||||
|
||||
orderClauses.push(`"${fieldName}" ${normalizedDirection}`);
|
||||
}
|
||||
}
|
||||
|
||||
return orderClauses.length > 0 ? `ORDER BY ${orderClauses.join(', ')}` : '';
|
||||
};
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import { validateAllowedValue } from 'src/engine/core-modules/sql-sanitization/utils/validate-allowed-value.util';
|
||||
|
||||
describe('validateAllowedValue', () => {
|
||||
const allowedFruits = ['apple', 'banana', 'cherry'] as const;
|
||||
|
||||
it('should accept allowed values', () => {
|
||||
expect(() =>
|
||||
validateAllowedValue('apple', allowedFruits, 'fruit'),
|
||||
).not.toThrow();
|
||||
expect(() =>
|
||||
validateAllowedValue('banana', allowedFruits, 'fruit'),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('should reject disallowed values', () => {
|
||||
expect(() => validateAllowedValue('mango', allowedFruits, 'fruit')).toThrow(
|
||||
'Invalid fruit: mango',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject empty string when not in allowed list', () => {
|
||||
expect(() => validateAllowedValue('', allowedFruits, 'fruit')).toThrow();
|
||||
});
|
||||
|
||||
it('should be case-sensitive', () => {
|
||||
expect(() =>
|
||||
validateAllowedValue('Apple', allowedFruits, 'fruit'),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
// Runtime validation that a string value is one of the allowed values.
|
||||
// Use for values that will be interpolated into SQL to ensure they
|
||||
// match a known-safe set (e.g. enum values, action keywords).
|
||||
export const validateAllowedValue = (
|
||||
value: string,
|
||||
allowedValues: readonly string[],
|
||||
label: string,
|
||||
): void => {
|
||||
if (!allowedValues.includes(value)) {
|
||||
throw new Error(`Invalid ${label}: ${value}`);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user