Add @mention support in AI Chat input (#17943)

## Summary
- Add `@mention` support to the AI Chat text input by replacing the
plain textarea with a minimal Tiptap editor and building a shared
`mention` module with reusable Tiptap extensions (`MentionTag`,
`MentionSuggestion`), search hook (`useMentionSearch`), and suggestion
menu — all shared with the existing BlockNote-based Notes mentions to
avoid code duplication
- Mentions are serialized as
`[[record:objectName:recordId:displayName]]` markdown (the format
already understood by the backend and rendered in chat messages), and
displayed using the existing `RecordLink` chip component for visual
consistency
- Fix images in chat messages overflowing their container by
constraining to `max-width: 100%`
- Fix web_search tool display showing literal `{query}` instead of the
actual query (ICU single-quote escaping issue in Lingui `t` tagged
templates)

## Test plan
- [ ] Open AI Chat, type `@` and verify the suggestion menu appears with
searchable records
- [ ] Select a mention from the dropdown (via click or keyboard
Enter/ArrowUp/Down) and verify the record chip renders inline
- [ ] Send a message containing a mention and verify it appears
correctly in the conversation as a clickable `RecordLink`
- [ ] Verify Enter sends the message when the suggestion menu is closed,
and selects a mention when the menu is open
- [ ] Verify images in AI chat responses are constrained to the
container width
- [ ] Verify the web_search tool step shows the actual search query
(e.g. "Searched the web for Salesforce") instead of `{query}`
- [ ] Verify Notes @mentions still work as before


Made with [Cursor](https://cursor.com)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Félix Malfait
2026-02-14 14:37:33 +01:00
committed by GitHub
parent 0876197c8d
commit 6f251a6f8e
72 changed files with 1920 additions and 1043 deletions
@@ -1 +0,0 @@
export const MENTION_MENU_LIST_ID = 'mention-menu-list-id';
@@ -1,193 +0,0 @@
import { filterSuggestionItems } from '@blocknote/core';
import { BlockNoteView } from '@blocknote/mantine';
import { SuggestionMenuController } from '@blocknote/react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { type ClipboardEvent } from 'react';
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { getSlashMenu } from '@/activities/blocks/utils/getSlashMenu';
import { CustomMentionMenu } from '@/ui/input/editor/components/CustomMentionMenu';
import { CustomSideMenu } from '@/ui/input/editor/components/CustomSideMenu';
import {
CustomSlashMenu,
type SuggestionItem,
} from '@/ui/input/editor/components/CustomSlashMenu';
import { useMentionMenu } from '@/ui/input/editor/hooks/useMentionMenu';
interface BlockEditorProps {
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
onFocus?: () => void;
onBlur?: () => void;
onPaste?: (event: ClipboardEvent) => void;
onChange?: () => void;
readonly?: boolean;
}
// eslint-disable-next-line twenty/no-hardcoded-colors
const StyledEditor = styled.div`
width: 100%;
& .editor {
background: transparent;
font-size: 13px;
color: ${({ theme }) => theme.font.color.primary};
min-height: 400px;
}
& .editor [class^='_inlineContent']:before {
color: ${({ theme }) => theme.font.color.tertiary};
font-style: normal !important;
}
& .editor .bn-inline-content:has(> .ProseMirror-trailingBreak):before {
font-style: normal;
}
& .mantine-ActionIcon-icon {
height: 20px;
width: 20px;
background: transparent;
}
& .bn-container .bn-drag-handle {
width: 20px;
height: 20px;
}
& .bn-block-content[data-content-type='checkListItem'] > div > div {
display: flex;
align-items: center;
}
& .bn-drag-handle-menu {
background: ${({ theme }) => theme.background.transparent.secondary};
backdrop-filter: ${({ theme }) => theme.blur.medium};
box-shadow:
0px 2px 4px rgba(0, 0, 0, 0.04),
2px 4px 16px rgba(0, 0, 0, 0.12);
min-width: 160px;
min-height: 96px;
padding: 4px;
border-radius: 8px;
border: 1px solid ${({ theme }) => theme.border.color.medium};
left: 26px;
}
& .bn-editor {
padding-inline: 0px;
}
& .bn-inline-content {
width: 100%;
}
& .bn-container .bn-suggestion-menu-item:hover {
background-color: blue;
}
& .bn-suggestion-menu {
padding: 4px;
border-radius: 8px;
border: 1px solid ${({ theme }) => theme.border.color.medium};
background: ${({ theme }) => theme.background.transparent.secondary};
backdrop-filter: ${({ theme }) => theme.blur.medium};
}
& .mantine-Menu-item {
background-color: transparent;
min-width: 152px;
min-height: 32px;
font-style: normal;
font-family: ${({ theme }) => theme.font.family};
font-weight: ${({ theme }) => theme.font.weight.regular};
color: ${({ theme }) => theme.font.color.secondary};
}
& .mantine-ActionIcon-root:hover {
box-shadow:
0px 0px 4px rgba(0, 0, 0, 0.08),
0px 2px 4px rgba(0, 0, 0, 0.04);
background: ${({ theme }) => theme.background.transparent.primary};
backdrop-filter: blur(20px);
border: 1px solid ${({ theme }) => theme.border.color.light};
}
& .bn-side-menu .mantine-UnstyledButton-root:not(.mantine-Menu-item) svg {
height: 16px;
width: 16px;
}
& .bn-mantine .bn-side-menu > [draggable='true'] {
margin-bottom: 5px;
}
& .bn-color-picker-dropdown {
margin-left: 8px;
}
& .bn-inline-content a {
color: ${({ theme }) => theme.color.blue};
}
& .bn-inline-content code {
font-family: monospace;
color: ${({ theme }) => theme.font.color.danger};
padding: 2px 4px;
border-radius: 4px;
border: 1px solid ${({ theme }) => theme.font.color.extraLight};
font-size: 0.9rem;
background-color: ${({ theme }) => theme.background.transparent.light};
}
`;
export const BlockEditor = ({
editor,
onFocus,
onBlur,
onChange,
onPaste,
readonly,
}: BlockEditorProps) => {
const theme = useTheme();
const blockNoteTheme = theme.name === 'light' ? 'light' : 'dark';
const getMentionItems = useMentionMenu(editor);
const handleFocus = () => {
onFocus?.();
};
const handleBlur = () => {
onBlur?.();
};
const handleChange = () => {
onChange?.();
};
const handlePaste = (event: ClipboardEvent) => {
onPaste?.(event);
};
return (
<StyledEditor>
<BlockNoteView
onFocus={handleFocus}
onBlur={handleBlur}
onPaste={handlePaste}
onChange={handleChange}
editor={editor}
theme={blockNoteTheme}
slashMenu={false}
sideMenu={false}
editable={!readonly}
>
<CustomSideMenu editor={editor} />
<SuggestionMenuController
triggerCharacter="/"
getItems={async (query) =>
filterSuggestionItems<SuggestionItem>(getSlashMenu(editor), query)
}
suggestionMenuComponent={CustomSlashMenu}
/>
<SuggestionMenuController
triggerCharacter="@"
getItems={async (query) => getMentionItems(query)}
suggestionMenuComponent={CustomMentionMenu}
/>
</BlockNoteView>
</StyledEditor>
);
};
@@ -1,66 +0,0 @@
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { isSlashMenuOpenComponentState } from '@/ui/input/editor/states/isSlashMenuOpenComponentState';
import { useGoBackToPreviousDropdownFocusId } from '@/ui/layout/dropdown/hooks/useGoBackToPreviousDropdownFocusId';
import { useSetActiveDropdownFocusIdAndMemorizePrevious } from '@/ui/layout/dropdown/hooks/useSetFocusedDropdownIdAndMemorizePrevious';
import { useRecoilComponentCallbackState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackState';
import { useRecoilCallback } from 'recoil';
export type BlockEditorDropdownFocusEffectProps = {
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
};
export const BlockEditorDropdownFocusEffect = ({
editor,
}: BlockEditorDropdownFocusEffectProps) => {
const isSlashMenuOpenState = useRecoilComponentCallbackState(
isSlashMenuOpenComponentState,
);
const { setActiveDropdownFocusIdAndMemorizePrevious } =
useSetActiveDropdownFocusIdAndMemorizePrevious();
const { goBackToPreviousDropdownFocusId } =
useGoBackToPreviousDropdownFocusId();
const updateCallBack = useRecoilCallback(
({ snapshot, set }) =>
(event: any) => {
// TODO: This triggers before the onClick event of the slash menu item, so the click outside of the editor dropdown is triggered and everything closes.
// This is due to useRecoilCallback being executed before the onClick event of the slash menu item.
const eventWantsToOpen = event.show === true;
const isAlreadyOpen = snapshot
.getLoadable(isSlashMenuOpenState)
.getValue();
const shouldOpen = eventWantsToOpen && !isAlreadyOpen;
if (shouldOpen) {
setActiveDropdownFocusIdAndMemorizePrevious('custom-slash-menu');
set(isSlashMenuOpenState, true);
return;
}
const eventWantsToClose = event.show === false;
const isAlreadyClosed = !isAlreadyOpen;
const shouldClose = eventWantsToClose && !isAlreadyClosed;
if (shouldClose) {
goBackToPreviousDropdownFocusId();
set(isSlashMenuOpenState, false);
return;
}
},
[
isSlashMenuOpenState,
setActiveDropdownFocusIdAndMemorizePrevious,
goBackToPreviousDropdownFocusId,
],
);
editor.suggestionMenus.on('update /', updateCallBack);
return <></>;
};
@@ -1,45 +0,0 @@
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { useComponentsContext } from '@blocknote/react';
type CustomAddBlockItemProps = {
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
children: React.ReactNode; // Adding the children prop
};
type ContentItem = {
type: string;
text: string;
styles: any;
};
export const CustomAddBlockItem = ({
editor,
children,
}: CustomAddBlockItemProps) => {
const Components = useComponentsContext();
if (!Components) {
return null;
}
const handleClick = () => {
const blockIdentifier = editor.getTextCursorPosition().block;
const currentBlockContent = blockIdentifier?.content as
| Array<ContentItem>
| undefined;
const [firstElement] = currentBlockContent || [];
if (firstElement === undefined) {
editor.openSuggestionMenu('/');
} else {
editor.openSuggestionMenu('/');
editor.sideMenu.unfreezeMenu();
}
};
return (
<Components.Generic.Menu.Item onClick={handleClick}>
{children}
</Components.Generic.Menu.Item>
);
};
@@ -1,101 +0,0 @@
import styled from '@emotion/styled';
import { autoUpdate, useFloating } from '@floating-ui/react';
import { motion } from 'framer-motion';
import { type MouseEvent as ReactMouseEvent } from 'react';
import { createPortal } from 'react-dom';
import { MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/MentionMenuDropdownClickOutsideId';
import { MENTION_MENU_LIST_ID } from '@/ui/input/constants/MentionMenuListId';
import { CustomMentionMenuListItem } from '@/ui/input/editor/components/CustomMentionMenuListItem';
import { CustomMentionMenuSelectedIndexSyncEffect } from '@/ui/input/editor/components/CustomMentionMenuSelectedIndexSyncEffect';
import {
type CustomMentionMenuProps,
type MentionItem,
} from '@/ui/input/editor/components/types';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { isDefined } from 'twenty-shared/utils';
export type { MentionItem };
const MenuPixelWidth = 240;
const StyledContainer = styled.div`
height: 1px;
width: 1px;
`;
export const CustomMentionMenu = ({
items,
selectedIndex,
onItemClick,
}: CustomMentionMenuProps) => {
const { refs, floatingStyles } = useFloating({
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
});
const handleContainerClick = (e: ReactMouseEvent) => {
e.stopPropagation();
};
if (!isDefined(items) || items.length === 0) {
return null;
}
const filteredItems = items.filter(
(item) =>
isDefined(item.recordId) &&
isDefined(item.objectNameSingular) &&
isDefined(item.objectMetadataId),
);
return (
<StyledContainer ref={refs.setReference}>
<CustomMentionMenuSelectedIndexSyncEffect
items={filteredItems}
selectedIndex={selectedIndex}
/>
<>
{createPortal(
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1 }}
onClick={handleContainerClick}
>
<OverlayContainer
ref={refs.setFloating}
style={floatingStyles}
data-click-outside-id={MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
>
<DropdownContent widthInPixels={MenuPixelWidth}>
<DropdownMenuItemsContainer hasMaxHeight>
<SelectableList
focusId={MENTION_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
selectableListInstanceId={MENTION_MENU_LIST_ID}
selectableItemIdArray={filteredItems.map(
(item) => item.recordId!,
)}
>
{filteredItems.map((item) => (
<CustomMentionMenuListItem
key={item.recordId!}
recordId={item.recordId!}
onClick={() => onItemClick?.(item)}
objectNameSingular={item.objectNameSingular!}
/>
))}
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
</OverlayContainer>
</motion.div>,
document.body,
)}
</>
</StyledContainer>
);
};
@@ -1,69 +0,0 @@
import { type MouseEvent } from 'react';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
import { MENTION_MENU_LIST_ID } from '@/ui/input/constants/MentionMenuListId';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { Avatar } from 'twenty-ui/display';
import { MenuItemSuggestion } from 'twenty-ui/navigation';
type CustomMentionMenuListItemProps = {
recordId: string;
onClick: () => void;
objectNameSingular: string;
};
export const CustomMentionMenuListItem = ({
recordId,
onClick,
objectNameSingular,
}: CustomMentionMenuListItemProps) => {
const { resetSelectedItem } = useSelectableList(MENTION_MENU_LIST_ID);
const isSelectedItem = useRecoilComponentFamilyValue(
isSelectedItemIdComponentFamilySelector,
recordId,
);
const searchRecord = useRecoilValue(searchRecordStoreFamilyState(recordId));
const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular });
const handleClick = (event?: MouseEvent) => {
event?.preventDefault();
event?.stopPropagation();
resetSelectedItem();
onClick();
};
if (!isDefined(searchRecord)) {
return null;
}
return (
<SelectableListItem itemId={recordId} onEnter={handleClick}>
<MenuItemSuggestion
selected={isSelectedItem}
onClick={handleClick}
text={`${searchRecord.label}`}
contextualText={objectMetadataItem.labelSingular}
contextualTextPosition="left"
LeftIcon={() => (
<Avatar
placeholder={searchRecord.label}
placeholderColorSeed={recordId}
avatarUrl={searchRecord.imageUrl}
type={getAvatarType(objectNameSingular) ?? 'rounded'}
size="sm"
/>
)}
/>
</SelectableListItem>
);
};
@@ -1,29 +0,0 @@
import { MENTION_MENU_LIST_ID } from '@/ui/input/constants/MentionMenuListId';
import { type MentionItem } from '@/ui/input/editor/components/types';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
type CustomMentionMenuSelectedIndexSyncEffectProps = {
items: MentionItem[];
selectedIndex: number | undefined;
};
export const CustomMentionMenuSelectedIndexSyncEffect = ({
items,
selectedIndex,
}: CustomMentionMenuSelectedIndexSyncEffectProps) => {
const { setSelectedItemId } = useSelectableList(MENTION_MENU_LIST_ID);
useEffect(() => {
if (!isDefined(selectedIndex) || !isDefined(items)) return;
const selectedItem = items[selectedIndex];
if (isDefined(selectedItem) && isDefined(selectedItem.recordId)) {
setSelectedItemId(selectedItem.recordId);
}
}, [items, selectedIndex, setSelectedItemId]);
return <></>;
};
@@ -1,69 +0,0 @@
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { CustomAddBlockItem } from '@/ui/input/editor/components/CustomAddBlockItem';
import { CustomSideMenuOptions } from '@/ui/input/editor/components/CustomSideMenuOptions';
import {
BlockColorsItem,
DragHandleButton,
DragHandleMenu,
RemoveBlockItem,
SideMenu,
SideMenuController,
} from '@blocknote/react';
import styled from '@emotion/styled';
import { useLingui } from '@lingui/react/macro';
import { IconColorSwatch, IconPlus, IconTrash } from 'twenty-ui/display';
type CustomSideMenuProps = {
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
};
const StyledDivToCreateGap = styled.div`
width: ${({ theme }) => theme.spacing(2)};
`;
export const CustomSideMenu = ({ editor }: CustomSideMenuProps) => {
const { t } = useLingui();
return (
<SideMenuController
sideMenu={(props) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<SideMenu {...props}>
<DragHandleButton
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
dragHandleMenu={(props) => (
// eslint-disable-next-line react/jsx-props-no-spreading
<DragHandleMenu {...props}>
<CustomAddBlockItem editor={editor}>
<CustomSideMenuOptions
LeftIcon={IconPlus}
text={t`Add Block`}
Variant="normal"
/>
</CustomAddBlockItem>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<BlockColorsItem {...props}>
<CustomSideMenuOptions
LeftIcon={IconColorSwatch}
text={t`Change Color`}
Variant="normal"
/>
</BlockColorsItem>
{/* eslint-disable-next-line react/jsx-props-no-spreading */}
<RemoveBlockItem {...props}>
{' '}
<CustomSideMenuOptions
LeftIcon={IconTrash}
text={t`Delete`}
Variant="danger"
/>
</RemoveBlockItem>
</DragHandleMenu>
)}
/>
<StyledDivToCreateGap />
</SideMenu>
)}
/>
);
};
@@ -1,39 +0,0 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { type IconComponent } from 'twenty-ui/display';
const StyledContainer = styled.div<{ Variant: Variants }>`
color: ${({ theme, Variant }) =>
Variant === 'danger' ? theme.color.red : 'inherit'};
align-items: center;
display: flex;
flex-direction: row;
gap: ${({ theme }) => theme.spacing(2)};
`;
const StyledTextContainer = styled.div``;
type CustomSideMenuOptionsProps = {
LeftIcon: IconComponent; // Any valid React node (e.g., a component)
Variant: Variants;
text: string;
};
type Variants = 'normal' | 'danger';
export const CustomSideMenuOptions = ({
LeftIcon,
Variant,
text,
}: CustomSideMenuOptionsProps) => {
const theme = useTheme();
return (
<StyledContainer Variant={Variant}>
<LeftIcon
size={theme.icon.size.md}
stroke={theme.icon.stroke.sm}
></LeftIcon>
<StyledTextContainer>{text}</StyledTextContainer>
</StyledContainer>
);
};
@@ -1,97 +0,0 @@
import { useBlockNoteEditor } from '@blocknote/react';
import styled from '@emotion/styled';
import { autoUpdate, flip, offset, useFloating } from '@floating-ui/react';
import { motion } from 'framer-motion';
import { createPortal } from 'react-dom';
import { SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID } from '@/ui/input/constants/SlashMenuDropdownClickOutsideId';
import { SLASH_MENU_LIST_ID } from '@/ui/input/constants/SlashMenuListId';
import { CustomSlashMenuListItem } from '@/ui/input/editor/components/CustomSlashMenuListItem';
import { CustomSlashMenuSelectedIndexSyncEffect } from '@/ui/input/editor/components/CustomSlashMenuSelectedIndexSyncEffect';
import type {
CustomSlashMenuProps,
SuggestionItem,
} from '@/ui/input/editor/components/types';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
export type { SuggestionItem };
const StyledContainer = styled.div`
height: 1px;
width: 1px;
`;
export const CustomSlashMenu = ({
items,
selectedIndex,
}: CustomSlashMenuProps) => {
const editor = useBlockNoteEditor();
const currentBlock = editor?.getTextCursorPosition()?.block;
const blockType = currentBlock?.type;
const headingLevel =
blockType === 'heading' ? (currentBlock?.props?.level as number) : null;
const getOffsetValue = (placement: string) => {
if (!placement.startsWith('top')) return 0;
switch (headingLevel) {
case 1:
return 65;
case 2:
return 50;
case 3:
return 45;
default:
return 40;
}
};
const { refs, floatingStyles } = useFloating({
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
middleware: [flip(), offset(({ placement }) => getOffsetValue(placement))],
});
return (
<StyledContainer ref={refs.setReference}>
<CustomSlashMenuSelectedIndexSyncEffect
items={items}
selectedIndex={selectedIndex}
/>
<>
{createPortal(
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1 }}
>
<OverlayContainer
ref={refs.setFloating}
style={floatingStyles}
data-click-outside-id={SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
>
<DropdownContent>
<DropdownMenuItemsContainer hasMaxHeight>
<SelectableList
focusId={SLASH_MENU_DROPDOWN_CLICK_OUTSIDE_ID}
selectableListInstanceId={SLASH_MENU_LIST_ID}
selectableItemIdArray={items.map((item) => item.title)}
>
{items.map((item) => (
<CustomSlashMenuListItem key={item.title} item={item} />
))}
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
</OverlayContainer>
</motion.div>,
document.body,
)}
</>
</StyledContainer>
);
};
@@ -1,38 +0,0 @@
import { SLASH_MENU_LIST_ID } from '@/ui/input/constants/SlashMenuListId';
import { type SuggestionItem } from '@/ui/input/editor/components/types';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { isSelectedItemIdComponentFamilySelector } from '@/ui/layout/selectable-list/states/selectors/isSelectedItemIdComponentFamilySelector';
import { useRecoilComponentFamilyValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValue';
import { MenuItemSuggestion } from 'twenty-ui/navigation';
export type CustomSlashMenuListItemProps = {
item: SuggestionItem;
};
export const CustomSlashMenuListItem = ({
item,
}: CustomSlashMenuListItemProps) => {
const { resetSelectedItem } = useSelectableList(SLASH_MENU_LIST_ID);
const isSelectedItem = useRecoilComponentFamilyValue(
isSelectedItemIdComponentFamilySelector,
item.title,
);
const handleClick = () => {
resetSelectedItem();
item.onItemClick();
};
return (
<SelectableListItem itemId={item.title} onEnter={handleClick}>
<MenuItemSuggestion
selected={isSelectedItem}
onClick={handleClick}
LeftIcon={item.Icon}
text={item.title}
/>
</SelectableListItem>
);
};
@@ -1,29 +0,0 @@
import { SLASH_MENU_LIST_ID } from '@/ui/input/constants/SlashMenuListId';
import { type SuggestionItem } from '@/ui/input/editor/components/types';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { useEffect } from 'react';
import { isDefined } from 'twenty-shared/utils';
type CustomSlashMenuSelectedIndexSyncEffectProps = {
items: SuggestionItem[];
selectedIndex: number | undefined;
};
export const CustomSlashMenuSelectedIndexSyncEffect = ({
items,
selectedIndex,
}: CustomSlashMenuSelectedIndexSyncEffectProps) => {
const { setSelectedItemId } = useSelectableList(SLASH_MENU_LIST_ID);
useEffect(() => {
if (!isDefined(selectedIndex)) return;
const selectedItem = items[selectedIndex];
if (isDefined(selectedItem)) {
setSelectedItemId(selectedItem.title);
}
}, [items, selectedIndex, setSelectedItemId]);
return <></>;
};
@@ -1,20 +0,0 @@
import type {
DefaultReactSuggestionItem,
SuggestionMenuProps,
} from '@blocknote/react';
import { type IconComponent } from 'twenty-ui/display';
export type SuggestionItem = DefaultReactSuggestionItem & {
aliases?: string[];
Icon?: IconComponent;
};
export type CustomSlashMenuProps = SuggestionMenuProps<SuggestionItem>;
export type MentionItem = DefaultReactSuggestionItem & {
recordId?: string;
objectNameSingular?: string;
objectMetadataId?: string;
};
export type CustomMentionMenuProps = SuggestionMenuProps<MentionItem>;
@@ -1,4 +0,0 @@
export const BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG = {
enableGlobalHotkeysConflictingWithKeyboard: false,
enableGlobalHotkeysWithModifiers: true,
};
@@ -1,4 +0,0 @@
import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext';
export const BlockEditorComponentInstanceContext =
createComponentInstanceContext();
@@ -1,97 +0,0 @@
import { type Attachment } from '@/activities/files/types/Attachment';
import { filterAttachmentsToRestore } from '@/activities/utils/filterAttachmentsToRestore';
import { getActivityAttachmentIdsAndNameToUpdate } from '@/activities/utils/getActivityAttachmentIdsAndNameToUpdate';
import { getActivityAttachmentIdsToDelete } from '@/activities/utils/getActivityAttachmentIdsToDelete';
import { getActivityAttachmentPathsToRestore } from '@/activities/utils/getActivityAttachmentPathsToRestore';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import { FeatureFlagKey } from '~/generated-metadata/graphql';
export const useAttachmentSync = (attachments: Attachment[]) => {
const isFilesFieldMigrated = useIsFeatureEnabled(
FeatureFlagKey.IS_FILES_FIELD_MIGRATED,
);
const { deleteManyRecords: deleteAttachments } = useDeleteManyRecords({
objectNameSingular: CoreObjectNameSingular.Attachment,
});
const { restoreManyRecords: restoreAttachments } = useRestoreManyRecords({
objectNameSingular: CoreObjectNameSingular.Attachment,
});
const { fetchAllRecords: findSoftDeletedAttachments } =
useLazyFetchAllRecords({
objectNameSingular: CoreObjectNameSingular.Attachment,
filter: {
deletedAt: {
is: 'NOT_NULL',
},
},
});
const { updateOneRecord } = useUpdateOneRecord();
const syncAttachments = async (
newBody: string,
previousBody?: string | null,
) => {
if (!newBody) return;
const previousBodyOrEmptyArray = previousBody?.trim() ? previousBody : '[]';
const attachmentIdsToDelete = getActivityAttachmentIdsToDelete(
newBody,
attachments,
previousBodyOrEmptyArray,
isFilesFieldMigrated,
);
if (attachmentIdsToDelete.length > 0) {
await deleteAttachments({
recordIdsToDelete: attachmentIdsToDelete,
});
}
const attachmentPathsToRestore = getActivityAttachmentPathsToRestore(
newBody,
attachments,
isFilesFieldMigrated,
);
if (attachmentPathsToRestore.length > 0) {
const softDeletedAttachments =
(await findSoftDeletedAttachments()) as Attachment[];
const attachmentIdsToRestore = filterAttachmentsToRestore({
attachmentPathsToRestore,
softDeletedAttachments: softDeletedAttachments ?? [],
isFilesFieldMigrated,
});
await restoreAttachments({
idsToRestore: attachmentIdsToRestore,
});
}
const attachmentsToUpdate = getActivityAttachmentIdsAndNameToUpdate(
newBody,
attachments,
isFilesFieldMigrated,
);
for (const attachmentToUpdate of attachmentsToUpdate) {
if (!attachmentToUpdate.id || !attachmentToUpdate.name) continue;
await updateOneRecord({
objectNameSingular: CoreObjectNameSingular.Attachment,
idToUpdate: attachmentToUpdate.id,
updateOneRecordInput: { name: attachmentToUpdate.name },
});
}
};
return { syncAttachments };
};
@@ -1,96 +0,0 @@
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
import { SEARCH_QUERY } from '@/command-menu/graphql/queries/search';
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { searchRecordStoreFamilyState } from '@/object-record/record-picker/multiple-record-picker/states/searchRecordStoreComponentFamilyState';
import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId';
import { type MentionItem } from '@/ui/input/editor/components/types';
import { useMemo } from 'react';
import { useRecoilCallback } from 'recoil';
import {
type SearchQuery,
type SearchQueryVariables,
} from '~/generated/graphql';
const MENTION_SEARCH_LIMIT = 50;
export const useMentionMenu = (editor: typeof BLOCK_SCHEMA.BlockNoteEditor) => {
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const apolloCoreClient = useApolloCoreClient();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const searchableObjectMetadataItems = useMemo(
() =>
activeObjectMetadataItems.filter(
(item) =>
!item.isSystem &&
item.isSearchable &&
getObjectPermissionsFromMapByObjectMetadataId({
objectPermissionsByObjectMetadataId,
objectMetadataId: item.id,
}).canReadObjectRecords === true,
),
[activeObjectMetadataItems, objectPermissionsByObjectMetadataId],
);
const objectsToSearch = useMemo(
() => searchableObjectMetadataItems.map(({ nameSingular }) => nameSingular),
[searchableObjectMetadataItems],
);
const getMentionItems = useRecoilCallback(
({ set }) =>
async (query: string): Promise<MentionItem[]> => {
const { data } = await apolloCoreClient.query<
SearchQuery,
SearchQueryVariables
>({
query: SEARCH_QUERY,
variables: {
searchInput: query,
limit: MENTION_SEARCH_LIMIT,
includedObjectNameSingulars: objectsToSearch,
},
});
const searchRecords = data?.search.edges.map((edge) => edge.node) || [];
searchRecords.forEach((searchRecord) => {
set(searchRecordStoreFamilyState(searchRecord.recordId), {
...searchRecord,
record: undefined,
});
});
return searchRecords.map((searchRecord) => {
const objectMetadataItem = searchableObjectMetadataItems.find(
(item) => item.nameSingular === searchRecord.objectNameSingular,
);
return {
title: searchRecord.label,
recordId: searchRecord.recordId,
objectNameSingular: searchRecord.objectNameSingular,
objectMetadataId: objectMetadataItem?.id,
onItemClick: () => {
editor.insertInlineContent([
{
type: 'mention',
props: {
recordId: searchRecord.recordId,
objectMetadataId: objectMetadataItem?.id ?? '',
},
},
' ',
]);
},
};
});
},
[apolloCoreClient, editor, objectsToSearch, searchableObjectMetadataItems],
);
return getMentionItems;
};
@@ -1,8 +0,0 @@
import { BlockEditorComponentInstanceContext } from '@/ui/input/editor/contexts/BlockEditorCompoponeInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
export const isSlashMenuOpenComponentState = createComponentState<boolean>({
key: 'isSlashMenuOpenComponentState',
defaultValue: false,
componentInstanceContext: BlockEditorComponentInstanceContext,
});
@@ -1,77 +0,0 @@
import type { PartialBlock } from '@blocknote/core';
import { getFirstNonEmptyLineOfRichText } from '@/ui/input/editor/utils/getFirstNonEmptyLineOfRichText';
describe('getFirstNonEmptyLineOfRichText', () => {
it('should return an empty string if the input is null', () => {
const result = getFirstNonEmptyLineOfRichText(null);
expect(result).toBe('');
});
it('should return an empty string if the input is an empty array', () => {
const result = getFirstNonEmptyLineOfRichText([]);
expect(result).toBe('');
});
it('should return the first non-empty line of text', () => {
const input: PartialBlock[] = [
{ content: [{ text: '', type: 'text', styles: {} }] },
{ content: [{ text: ' ', type: 'text', styles: {} }] },
{ content: [{ text: 'First non-empty line', type: 'text', styles: {} }] },
{ content: [{ text: 'Second line', type: 'text', styles: {} }] },
];
const result = getFirstNonEmptyLineOfRichText(input);
expect(result).toBe('First non-empty line');
});
it('should return an empty string if all lines are empty', () => {
const input: PartialBlock[] = [
{ content: [{ text: '', type: 'text', styles: {} }] },
{ content: [{ text: ' ', type: 'text', styles: {} }] },
{ content: [{ text: '\n', type: 'text', styles: {} }] },
];
const result = getFirstNonEmptyLineOfRichText(input);
expect(result).toBe('');
});
it('should handle mixed content correctly', () => {
const input: PartialBlock[] = [
{ content: [{ text: '', type: 'text', styles: {} }] },
{ content: [{ text: ' ', type: 'text', styles: {} }] },
{ content: [{ text: 'First non-empty line', type: 'text', styles: {} }] },
{ content: [{ text: '', type: 'text', styles: {} }] },
{
content: [{ text: 'Second non-empty line', type: 'text', styles: {} }],
},
];
const result = getFirstNonEmptyLineOfRichText(input);
expect(result).toBe('First non-empty line');
});
it('should handle content with multiple text objects correctly', () => {
const input: PartialBlock[] = [
{
content: [
{ text: '', type: 'text', styles: {} },
{ text: ' ', type: 'text', styles: {} },
],
},
{
content: [
{ text: 'First non-empty line', type: 'text', styles: {} },
{ text: 'Second line', type: 'text', styles: {} },
],
},
];
const result = getFirstNonEmptyLineOfRichText(input);
expect(result).toBe('First non-empty line');
});
it('should handle content with undefined or null content', () => {
const input: PartialBlock[] = [
{ content: undefined },
{ content: [{ text: 'First non-empty line', type: 'text', styles: {} }] },
];
const result = getFirstNonEmptyLineOfRichText(input);
expect(result).toBe('First non-empty line');
});
});
@@ -1,47 +0,0 @@
import { parseInitialBlocknote } from '@/ui/input/editor/utils/parseInitialBlocknote';
describe('parseInitialBlocknote', () => {
it('should parse valid JSON array string', () => {
const input = JSON.stringify([{ type: 'paragraph', content: 'test' }]);
const result = parseInitialBlocknote(input);
expect(result).toEqual([{ type: 'paragraph', content: 'test' }]);
});
it('should return undefined for empty string', () => {
expect(parseInitialBlocknote('')).toBeUndefined();
});
it('should return undefined for null', () => {
expect(parseInitialBlocknote(null)).toBeUndefined();
});
it('should return undefined for undefined', () => {
expect(parseInitialBlocknote(undefined)).toBeUndefined();
});
it('should return undefined for empty object string "{}"', () => {
expect(parseInitialBlocknote('{}')).toBeUndefined();
});
it('should return undefined for invalid JSON', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
expect(parseInitialBlocknote('invalid json')).toBeUndefined();
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
it('should return undefined for empty array', () => {
expect(parseInitialBlocknote('[]')).toBeUndefined();
});
it('should return undefined for non-array JSON', () => {
expect(parseInitialBlocknote('{"key": "value"}')).toBeUndefined();
});
it('should use custom log context when parsing fails', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
parseInitialBlocknote('invalid', 'Custom context');
expect(consoleSpy).toHaveBeenCalledWith('Custom context');
consoleSpy.mockRestore();
});
});
@@ -1,46 +0,0 @@
import { prepareBodyWithSignedUrls } from '@/ui/input/editor/utils/prepareBodyWithSignedUrls';
describe('prepareBodyWithSignedUrls', () => {
it('should return empty string as-is', () => {
expect(prepareBodyWithSignedUrls('')).toBe('');
});
it('should parse and re-stringify blocks', () => {
const input = JSON.stringify([{ type: 'paragraph', content: 'text' }]);
const result = JSON.parse(prepareBodyWithSignedUrls(input));
expect(result).toEqual([{ type: 'paragraph', content: 'text' }]);
});
it('should pass through non-image blocks unchanged', () => {
const blocks = [
{ type: 'paragraph', content: 'text' },
{ type: 'heading', content: 'title' },
{ type: 'bulletListItem', content: 'item' },
];
const result = JSON.parse(
prepareBodyWithSignedUrls(JSON.stringify(blocks)),
);
expect(result).toEqual(blocks);
});
it('should skip image blocks without props', () => {
const input = JSON.stringify([{ type: 'image' }]);
const result = JSON.parse(prepareBodyWithSignedUrls(input));
expect(result).toEqual([{ type: 'image' }]);
});
it('should skip image blocks without url in props', () => {
const input = JSON.stringify([{ type: 'image', props: { alt: 'test' } }]);
const result = JSON.parse(prepareBodyWithSignedUrls(input));
expect(result).toEqual([{ type: 'image', props: { alt: 'test' } }]);
});
it('should process image blocks with valid URLs', () => {
const input = JSON.stringify([
{ type: 'image', props: { url: 'https://example.com/image.png' } },
]);
const result = JSON.parse(prepareBodyWithSignedUrls(input));
expect(result[0].type).toBe('image');
expect(result[0].props.url).toContain('example.com');
});
});
@@ -1,32 +0,0 @@
import type { PartialBlock } from '@blocknote/core';
import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
export const getFirstNonEmptyLineOfRichText = (
blocks: PartialBlock[] | null,
): string => {
if (blocks === null) {
return '';
}
for (const block of blocks) {
if (!isUndefinedOrNull(block.content)) {
const contentArray = block.content as Array<
{ text: string } | { link: string }
>;
if (contentArray.length > 0) {
for (const content of contentArray) {
if ('link' in content) {
return content.link;
}
if ('text' in content) {
const value = content.text.trim();
if (value !== '') {
return value;
}
}
}
}
}
}
return '';
};
@@ -1,29 +0,0 @@
import type { PartialBlock } from '@blocknote/core';
import { isArray, isNonEmptyString } from '@sniptt/guards';
export const parseInitialBlocknote = (
blocknote?: string | null,
logContext?: string,
): PartialBlock[] | undefined => {
if (isNonEmptyString(blocknote) && blocknote !== '{}') {
let parsedBody: PartialBlock[] | undefined = undefined;
// TODO: Remove this once we have removed the old rich text
try {
parsedBody = JSON.parse(blocknote);
} catch {
// eslint-disable-next-line no-console
console.warn(logContext ?? `Failed to parse blocknote body`);
// eslint-disable-next-line no-console
console.warn(blocknote);
}
if (!isArray(parsedBody) || parsedBody.length === 0) {
return undefined;
}
return parsedBody;
}
return undefined;
};
@@ -1,30 +0,0 @@
import type { PartialBlock } from '@blocknote/core';
// TODO: This function is extracted but its not doing what it is supposed to do. It is not signing the urls. It is just parsing the image urls.
// tracking issue - https://github.com/twentyhq/twenty/issues/8351
export const prepareBodyWithSignedUrls = (
newStringifiedBody: string,
): string => {
if (!newStringifiedBody) return newStringifiedBody;
const body: PartialBlock[] = JSON.parse(newStringifiedBody);
const bodyWithSignedPayload = body.map((block) => {
if (block.type !== 'image' || !block.props?.url) {
return block;
}
const imageUrl = block.props.url;
const parsedImageUrl = new URL(imageUrl);
return {
...block,
props: {
...block.props,
url: parsedImageUrl.toString(),
},
};
});
return JSON.stringify(bodyWithSignedPayload);
};
@@ -0,0 +1,180 @@
import {
autoUpdate,
flip,
offset,
shift,
useFloating,
} from '@floating-ui/react';
import { motion } from 'framer-motion';
import {
forwardRef,
useImperativeHandle,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { OverlayContainer } from '@/ui/layout/overlay/components/OverlayContainer';
import type { SuggestionMenuProps } from '@/ui/suggestion/types/SuggestionMenuProps';
type SuggestionMenuInnerProps<TItem> = SuggestionMenuProps<TItem>;
// forwardRef does not natively support generics, so we use a helper + cast
const SuggestionMenuInner = <TItem,>(
props: SuggestionMenuInnerProps<TItem>,
parentRef: React.ForwardedRef<unknown>,
) => {
const { items, onSelect, editor, range, getItemKey, renderItem, onKeyDown } =
props;
const [selectedIndex, setSelectedIndex] = useState(0);
const clampedSelectedIndex =
items.length > 0 ? Math.min(selectedIndex, items.length - 1) : 0;
const activeItemRef = useRef<HTMLDivElement>(null);
const listContainerRef = useRef<HTMLDivElement>(null);
const positionReference = useMemo(
() => ({
getBoundingClientRect: () => {
const start = editor.view.coordsAtPos(range.from);
return new DOMRect(start.left, start.top, 0, start.bottom - start.top);
},
}),
[editor, range],
);
const { refs, floatingStyles } = useFloating({
placement: 'bottom-start',
strategy: 'fixed',
middleware: [offset(4), flip(), shift()],
whileElementsMounted: (reference, floating, update) => {
return autoUpdate(reference, floating, update, {
animationFrame: true,
});
},
elements: {
reference: positionReference,
},
});
const selectItem = (index: number) => {
const item = items[index];
if (!item) {
return;
}
onSelect(item);
};
useImperativeHandle(parentRef, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
const customResult = onKeyDown?.(event, clampedSelectedIndex);
if (customResult === true) {
return true;
}
const navigationKeys = ['ArrowUp', 'ArrowDown', 'Enter'];
if (navigationKeys.includes(event.key)) {
switch (event.key) {
case 'ArrowUp': {
if (!items.length) {
return false;
}
let newIndex = clampedSelectedIndex - 1;
if (newIndex < 0) {
newIndex = items.length - 1;
}
setSelectedIndex(newIndex);
return true;
}
case 'ArrowDown': {
if (!items.length) {
return false;
}
let newIndex = clampedSelectedIndex + 1;
if (newIndex >= items.length) {
newIndex = 0;
}
setSelectedIndex(newIndex);
return true;
}
case 'Enter':
if (!items.length) {
return false;
}
selectItem(clampedSelectedIndex);
return true;
default:
return false;
}
}
return false;
},
}));
useLayoutEffect(() => {
const container = listContainerRef?.current;
const activeItemContainer = activeItemRef?.current;
if (!container || !activeItemContainer) {
return;
}
const scrollableContainer =
container.firstElementChild as HTMLElement | null;
if (!scrollableContainer) {
return;
}
const { offsetTop, offsetHeight } = activeItemContainer;
scrollableContainer.style.transition = 'none';
scrollableContainer.scrollTop = offsetTop - offsetHeight;
}, [clampedSelectedIndex]);
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.1 }}
data-suggestion-menu
>
<OverlayContainer
ref={refs.setFloating}
style={{
...floatingStyles,
zIndex: RootStackingContextZIndices.DropdownPortalAboveModal,
}}
>
<DropdownContent ref={listContainerRef}>
<DropdownMenuItemsContainer hasMaxHeight>
{items.map((item, index) => {
const isSelected = index === clampedSelectedIndex;
return (
<div
key={getItemKey(item)}
ref={isSelected ? activeItemRef : null}
onMouseDown={(event) => {
event.preventDefault();
}}
>
{renderItem(item, isSelected)}
</div>
);
})}
</DropdownMenuItemsContainer>
</DropdownContent>
</OverlayContainer>
</motion.div>
);
};
export const SuggestionMenu = forwardRef(SuggestionMenuInner) as <TItem>(
props: SuggestionMenuProps<TItem> & { ref?: React.Ref<unknown> },
) => ReturnType<typeof SuggestionMenuInner>;
@@ -0,0 +1,186 @@
import { type Editor, type Range } from '@tiptap/core';
const mockUpdateProps = jest.fn();
const mockDestroy = jest.fn();
let mockElement: HTMLElement;
jest.mock('@tiptap/react', () => ({
ReactRenderer: jest.fn().mockImplementation(() => {
mockElement = document.createElement('div');
return {
element: mockElement,
ref: null,
updateProps: mockUpdateProps,
destroy: mockDestroy,
};
}),
}));
import { createSuggestionRenderLifecycle } from '@/ui/suggestion/components/createSuggestionRenderLifecycle';
type TestItem = { id: string; label: string };
type TestMenuProps = {
items: TestItem[];
onSelect: (item: TestItem) => void;
editor: Editor;
range: Range;
};
const mockEditor = {} as Editor;
const createTestLifecycle = () =>
createSuggestionRenderLifecycle<TestItem, TestMenuProps>(
{
component: (() => null) as unknown as React.ComponentType<TestMenuProps>,
getMenuProps: ({ items, onSelect, editor, range }) => ({
items,
onSelect,
editor,
range,
}),
},
mockEditor,
);
const createMockCallbackProps = (
overrides: Partial<{
items: TestItem[];
command: (item: TestItem) => void;
clientRect: (() => DOMRect | null) | null;
range: Range;
query: string;
}> = {},
) => ({
items: [{ id: '1', label: 'Item A' }],
command: jest.fn(),
clientRect: () => new DOMRect(0, 0, 100, 20),
range: { from: 0, to: 5 } as Range,
query: '',
...overrides,
});
describe('createSuggestionRenderLifecycle', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('onStart', () => {
it('should not create renderer when clientRect is missing', () => {
const lifecycle = createTestLifecycle();
lifecycle.onStart(createMockCallbackProps({ clientRect: undefined }));
const { ReactRenderer } = jest.requireMock('@tiptap/react');
expect(ReactRenderer).not.toHaveBeenCalled();
});
it('should not create renderer when items are empty', () => {
const lifecycle = createTestLifecycle();
lifecycle.onStart(createMockCallbackProps({ items: [] }));
const { ReactRenderer } = jest.requireMock('@tiptap/react');
expect(ReactRenderer).not.toHaveBeenCalled();
});
it('should create renderer and append element to body', () => {
const lifecycle = createTestLifecycle();
lifecycle.onStart(createMockCallbackProps());
const { ReactRenderer } = jest.requireMock('@tiptap/react');
expect(ReactRenderer).toHaveBeenCalledTimes(1);
expect(document.body.contains(mockElement)).toBe(true);
lifecycle.onExit();
});
});
describe('onUpdate', () => {
it('should close menu when items become empty', () => {
const lifecycle = createTestLifecycle();
lifecycle.onStart(createMockCallbackProps());
lifecycle.onUpdate(createMockCallbackProps({ items: [] }));
expect(mockDestroy).toHaveBeenCalled();
});
it('should update props on existing renderer', () => {
const lifecycle = createTestLifecycle();
lifecycle.onStart(createMockCallbackProps());
const newItems = [{ id: '2', label: 'Item B' }];
lifecycle.onUpdate(createMockCallbackProps({ items: newItems }));
expect(mockUpdateProps).toHaveBeenCalled();
lifecycle.onExit();
});
});
describe('onKeyDown', () => {
it('should close menu and return true on Escape', () => {
const lifecycle = createTestLifecycle();
lifecycle.onStart(createMockCallbackProps());
const result = lifecycle.onKeyDown({
event: new KeyboardEvent('keydown', { key: 'Escape' }),
});
expect(result).toBe(true);
expect(mockDestroy).toHaveBeenCalled();
});
it('should return false when no renderer exists', () => {
const lifecycle = createTestLifecycle();
const result = lifecycle.onKeyDown({
event: new KeyboardEvent('keydown', { key: 'ArrowDown' }),
});
expect(result).toBe(false);
});
});
describe('onExit', () => {
it('should clean up renderer', () => {
const lifecycle = createTestLifecycle();
lifecycle.onStart(createMockCallbackProps());
lifecycle.onExit();
expect(mockDestroy).toHaveBeenCalled();
});
it('should handle multiple onExit calls gracefully', () => {
const lifecycle = createTestLifecycle();
lifecycle.onStart(createMockCallbackProps());
lifecycle.onExit();
expect(() => lifecycle.onExit()).not.toThrow();
});
});
describe('command wrapping', () => {
it('should call original command and close menu on select', () => {
const lifecycle = createTestLifecycle();
const originalCommand = jest.fn();
lifecycle.onStart(createMockCallbackProps({ command: originalCommand }));
// Extract the onSelect callback from the props passed to ReactRenderer
const { ReactRenderer } = jest.requireMock('@tiptap/react');
const constructorCall = ReactRenderer.mock.calls[0];
const menuProps = constructorCall[1].props;
menuProps.onSelect({ id: '1', label: 'Item A' });
expect(originalCommand).toHaveBeenCalledWith({
id: '1',
label: 'Item A',
});
expect(mockDestroy).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,103 @@
import type { Editor, Range } from '@tiptap/core';
import { ReactRenderer } from '@tiptap/react';
type SuggestionMenuRef = {
onKeyDown?: (props: { event: KeyboardEvent }) => boolean;
};
type SuggestionCallbackProps<TItem> = {
items: TItem[];
command: (item: TItem) => void;
clientRect?: (() => DOMRect | null) | null;
range: Range;
query: string;
};
// Matches Tiptap's ReactRenderer generic constraint
type AnyRecord = Record<string, any>;
type SuggestionRenderLifecycleConfig<TItem, TMenuProps extends AnyRecord> = {
component: React.ComponentType<TMenuProps>;
getMenuProps: (args: {
items: TItem[];
onSelect: (item: TItem) => void;
editor: Editor;
range: Range;
query: string;
}) => TMenuProps;
};
export const createSuggestionRenderLifecycle = <
TItem,
TMenuProps extends AnyRecord,
>(
config: SuggestionRenderLifecycleConfig<TItem, TMenuProps>,
editor: Editor,
) => {
let renderer: ReactRenderer<SuggestionMenuRef, TMenuProps> | null = null;
const closeMenu = () => {
if (renderer !== null) {
renderer.destroy();
renderer = null;
}
};
const buildMenuProps = (props: SuggestionCallbackProps<TItem>) =>
config.getMenuProps({
items: props.items,
onSelect: (item: TItem) => {
props.command(item);
closeMenu();
},
editor,
range: props.range,
query: props.query,
});
const createRenderer = (props: SuggestionCallbackProps<TItem>) => {
renderer = new ReactRenderer(config.component, {
editor,
props: buildMenuProps(props),
});
document.body.appendChild(renderer.element);
};
return {
onStart: (props: SuggestionCallbackProps<TItem>) => {
if (!props.clientRect || props.items.length === 0) {
return;
}
createRenderer(props);
},
onUpdate: (props: SuggestionCallbackProps<TItem>) => {
if (!props.clientRect) {
return;
}
if (props.items.length === 0) {
closeMenu();
return;
}
if (renderer === null) {
createRenderer(props);
return;
}
renderer.updateProps(buildMenuProps(props));
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === 'Escape') {
closeMenu();
return true;
}
return renderer?.ref?.onKeyDown?.(props) ?? false;
},
onExit: () => {
closeMenu();
},
};
};
@@ -0,0 +1,15 @@
import type { Editor, Range } from '@tiptap/core';
import type { ReactNode } from 'react';
export type SuggestionMenuProps<TItem> = {
items: TItem[];
onSelect: (item: TItem) => void;
editor: Editor;
range: Range;
getItemKey: (item: TItem) => string;
renderItem: (item: TItem, isSelected: boolean) => ReactNode;
onKeyDown?: (
event: KeyboardEvent,
selectedIndex: number,
) => boolean | undefined;
};