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:
+21
-75
@@ -7,7 +7,11 @@ import {
|
||||
DEFAULT_SLASH_COMMANDS,
|
||||
type SlashCommandConfig,
|
||||
} from '@/advanced-text-editor/extensions/slash-command/DefaultSlashCommands';
|
||||
import { SlashCommandRenderer } from '@/advanced-text-editor/extensions/slash-command/SlashCommandRenderer';
|
||||
import {
|
||||
SlashCommandMenu,
|
||||
type SlashCommandMenuProps,
|
||||
} from '@/advanced-text-editor/extensions/slash-command/SlashCommandMenu';
|
||||
import { createSuggestionRenderLifecycle } from '@/ui/suggestion/components/createSuggestionRenderLifecycle';
|
||||
|
||||
export type SlashCommandItem = {
|
||||
id: string;
|
||||
@@ -20,14 +24,6 @@ export type SlashCommandItem = {
|
||||
command: (options: { editor: Editor; range: Range }) => void;
|
||||
};
|
||||
|
||||
type SuggestionRenderProps = {
|
||||
items: SlashCommandItem[];
|
||||
command: (item: SlashCommandItem) => void;
|
||||
clientRect?: (() => DOMRect | null) | null;
|
||||
range: Range;
|
||||
query: string;
|
||||
};
|
||||
|
||||
const createSlashCommandItem = (
|
||||
config: SlashCommandConfig,
|
||||
editor: Editor,
|
||||
@@ -95,73 +91,23 @@ export const SlashCommand = Extension.create<SlashCommandOptions>({
|
||||
editor: this.editor,
|
||||
...this.options.suggestions,
|
||||
items: ({ query, editor: ed }) => buildItems(ed, query),
|
||||
render: () => {
|
||||
let component: SlashCommandRenderer | null = null;
|
||||
|
||||
const closeMenu = () => {
|
||||
if (component !== null) {
|
||||
component.destroy();
|
||||
component = null;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
onStart: (props: SuggestionRenderProps) => {
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
component = new SlashCommandRenderer({
|
||||
items: props.items,
|
||||
command: (item: SlashCommandItem) => {
|
||||
props.command(item);
|
||||
closeMenu();
|
||||
},
|
||||
clientRect: props.clientRect,
|
||||
editor: this.editor,
|
||||
range: props.range,
|
||||
query: props.query,
|
||||
});
|
||||
render: () =>
|
||||
createSuggestionRenderLifecycle<
|
||||
SlashCommandItem,
|
||||
SlashCommandMenuProps
|
||||
>(
|
||||
{
|
||||
component: SlashCommandMenu,
|
||||
getMenuProps: ({ items, onSelect, editor, range, query }) => ({
|
||||
items,
|
||||
onSelect,
|
||||
editor,
|
||||
range,
|
||||
query,
|
||||
}),
|
||||
},
|
||||
onUpdate: (props: SuggestionRenderProps) => {
|
||||
if (component === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!props.clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.items.length === 0) {
|
||||
closeMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
component.updateProps({
|
||||
items: props.items,
|
||||
command: (item: SlashCommandItem) => {
|
||||
props.command(item);
|
||||
closeMenu();
|
||||
},
|
||||
clientRect: props.clientRect,
|
||||
editor: this.editor,
|
||||
range: props.range,
|
||||
query: props.query,
|
||||
});
|
||||
},
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === 'Escape') {
|
||||
closeMenu();
|
||||
return true;
|
||||
}
|
||||
|
||||
return component?.ref?.onKeyDown?.(props) ?? false;
|
||||
},
|
||||
onExit: () => {
|
||||
closeMenu();
|
||||
},
|
||||
};
|
||||
},
|
||||
this.editor,
|
||||
),
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
+48
-191
@@ -1,220 +1,77 @@
|
||||
import { ThemeProvider } from '@emotion/react';
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
useFloating,
|
||||
} from '@floating-ui/react';
|
||||
import { type Editor, type Range } from '@tiptap/core';
|
||||
import { motion } from 'framer-motion';
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { forwardRef, useCallback, useState } from 'react';
|
||||
import { MenuItemSuggestion } from 'twenty-ui/navigation';
|
||||
import { THEME_DARK, THEME_LIGHT } from 'twenty-ui/theme';
|
||||
|
||||
import { type SlashCommandItem } from '@/advanced-text-editor/extensions/slash-command/SlashCommand';
|
||||
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 { SuggestionMenu } from '@/ui/suggestion/components/SuggestionMenu';
|
||||
|
||||
export type SlashCommandMenuProps = {
|
||||
items: SlashCommandItem[];
|
||||
onSelect: (item: SlashCommandItem) => void;
|
||||
clientRect: (() => DOMRect | null) | null;
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
query: string;
|
||||
};
|
||||
|
||||
const getItemKey = (item: SlashCommandItem) => item.id;
|
||||
|
||||
export const SlashCommandMenu = forwardRef<unknown, SlashCommandMenuProps>(
|
||||
(props, parentRef) => {
|
||||
(props, ref) => {
|
||||
const { items, onSelect, editor, range, query } = props;
|
||||
|
||||
const colorScheme = document.documentElement.className.includes('dark')
|
||||
? 'Dark'
|
||||
: 'Light';
|
||||
const theme = colorScheme === 'Dark' ? THEME_DARK : THEME_LIGHT;
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [prevSelectedIndex, setPrevSelectedIndex] = useState(0);
|
||||
const [prevQuery, setPrevQuery] = useState('');
|
||||
|
||||
const activeCommandRef = useRef<HTMLDivElement>(null);
|
||||
const commandListContainerRef = 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 = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
if (!item) {
|
||||
return;
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent, selectedIndex: number) => {
|
||||
if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault();
|
||||
editor.chain().focus().insertContentAt(range, `/${prevQuery}`).run();
|
||||
setTimeout(() => {
|
||||
setPrevSelectedIndex(prevSelectedIndex);
|
||||
}, 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
onSelect(item);
|
||||
if (event.key === 'ArrowRight') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && items.length > 0) {
|
||||
setPrevQuery(query);
|
||||
setPrevSelectedIndex(selectedIndex);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
[onSelect, items],
|
||||
[editor, range, prevQuery, prevSelectedIndex, items.length, query],
|
||||
);
|
||||
|
||||
useImperativeHandle(parentRef, () => ({
|
||||
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
||||
const navigationKeys = [
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'Enter',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
];
|
||||
if (navigationKeys.includes(event.key)) {
|
||||
let newCommandIndex = selectedIndex;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft':
|
||||
event.preventDefault();
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContentAt(range, `/${prevQuery}`)
|
||||
.run();
|
||||
setTimeout(() => {
|
||||
setSelectedIndex(prevSelectedIndex);
|
||||
}, 0);
|
||||
return true;
|
||||
case 'ArrowUp':
|
||||
if (!items.length) {
|
||||
return false;
|
||||
}
|
||||
newCommandIndex = selectedIndex - 1;
|
||||
if (newCommandIndex < 0) {
|
||||
newCommandIndex = items.length - 1;
|
||||
}
|
||||
setSelectedIndex(newCommandIndex);
|
||||
return true;
|
||||
case 'ArrowDown':
|
||||
if (!items.length) {
|
||||
return false;
|
||||
}
|
||||
newCommandIndex = selectedIndex + 1;
|
||||
if (newCommandIndex >= items.length) {
|
||||
newCommandIndex = 0;
|
||||
}
|
||||
setSelectedIndex(newCommandIndex);
|
||||
return true;
|
||||
case 'Enter':
|
||||
if (!items.length) {
|
||||
return false;
|
||||
}
|
||||
selectItem(selectedIndex);
|
||||
|
||||
setPrevQuery(query);
|
||||
setPrevSelectedIndex(selectedIndex);
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = commandListContainerRef?.current;
|
||||
const activeCommandContainer = activeCommandRef?.current;
|
||||
if (!container || !activeCommandContainer) {
|
||||
return;
|
||||
}
|
||||
const scrollableContainer =
|
||||
container.firstElementChild as HTMLElement | null;
|
||||
if (!scrollableContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { offsetTop, offsetHeight } = activeCommandContainer;
|
||||
|
||||
scrollableContainer.style.transition = 'none';
|
||||
scrollableContainer.scrollTop = offsetTop - offsetHeight;
|
||||
}, [selectedIndex]);
|
||||
const renderItem = useCallback(
|
||||
(item: SlashCommandItem, isSelected: boolean) => (
|
||||
<MenuItemSuggestion
|
||||
LeftIcon={item.icon}
|
||||
text={item.title}
|
||||
selected={isSelected}
|
||||
onClick={() => {
|
||||
onSelect(item);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
[onSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProvider theme={theme}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
data-slash-command-menu
|
||||
>
|
||||
<OverlayContainer
|
||||
ref={refs.setFloating}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
zIndex: RootStackingContextZIndices.DropdownPortalAboveModal,
|
||||
}}
|
||||
>
|
||||
<DropdownContent ref={commandListContainerRef}>
|
||||
<DropdownMenuItemsContainer hasMaxHeight>
|
||||
{items.map((item, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
ref={isSelected ? activeCommandRef : null}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<MenuItemSuggestion
|
||||
LeftIcon={item.icon}
|
||||
text={item.title}
|
||||
selected={isSelected}
|
||||
onClick={() => {
|
||||
onSelect(item);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
</OverlayContainer>
|
||||
</motion.div>
|
||||
</ThemeProvider>
|
||||
<SuggestionMenu
|
||||
ref={ref}
|
||||
items={items}
|
||||
onSelect={onSelect}
|
||||
editor={editor}
|
||||
range={range}
|
||||
getItemKey={getItemKey}
|
||||
renderItem={renderItem}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
|
||||
import type { SlashCommandItem } from '@/advanced-text-editor/extensions/slash-command/SlashCommand';
|
||||
import {
|
||||
SlashCommandMenu,
|
||||
type SlashCommandMenuProps,
|
||||
} from '@/advanced-text-editor/extensions/slash-command/SlashCommandMenu';
|
||||
import type { Editor, Range } from '@tiptap/core';
|
||||
|
||||
type SlashCommandRendererProps = {
|
||||
items: SlashCommandItem[];
|
||||
command: (item: SlashCommandItem) => void;
|
||||
clientRect: (() => DOMRect | null) | null;
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
query: string;
|
||||
};
|
||||
|
||||
export class SlashCommandRenderer {
|
||||
componentRoot: Root | null = null;
|
||||
containerElement: HTMLElement | null = null;
|
||||
currentProps: SlashCommandRendererProps | null = null;
|
||||
ref: { onKeyDown?: (props: { event: KeyboardEvent }) => boolean } | null =
|
||||
null;
|
||||
|
||||
constructor(props: SlashCommandRendererProps) {
|
||||
this.containerElement = document.createElement('div');
|
||||
document.body.appendChild(this.containerElement);
|
||||
|
||||
this.componentRoot = createRoot(this.containerElement);
|
||||
this.currentProps = props;
|
||||
this.render(props);
|
||||
}
|
||||
|
||||
render(props: SlashCommandRendererProps): void {
|
||||
if (!this.componentRoot) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuProps: SlashCommandMenuProps = {
|
||||
items: props.items,
|
||||
onSelect: props.command,
|
||||
clientRect: props.clientRect,
|
||||
editor: props.editor,
|
||||
range: props.range,
|
||||
query: props.query,
|
||||
};
|
||||
|
||||
this.componentRoot.render(
|
||||
createElement(SlashCommandMenu, {
|
||||
...menuProps,
|
||||
ref: (
|
||||
ref: {
|
||||
onKeyDown?: (props: { event: KeyboardEvent }) => boolean;
|
||||
} | null,
|
||||
) => {
|
||||
this.ref = ref;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
updateProps(props: Partial<SlashCommandRendererProps>): void {
|
||||
if (!this.componentRoot || !this.currentProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedProps = { ...this.currentProps, ...props };
|
||||
this.currentProps = updatedProps;
|
||||
this.render(updatedProps);
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.componentRoot !== null) {
|
||||
this.componentRoot.unmount();
|
||||
this.componentRoot = null;
|
||||
}
|
||||
|
||||
if (this.containerElement !== null) {
|
||||
this.containerElement.remove();
|
||||
this.containerElement = null;
|
||||
}
|
||||
|
||||
this.currentProps = null;
|
||||
this.ref = null;
|
||||
}
|
||||
}
|
||||
-327
@@ -1,327 +0,0 @@
|
||||
import { Editor } from '@tiptap/core';
|
||||
import { Document } from '@tiptap/extension-document';
|
||||
import { Paragraph } from '@tiptap/extension-paragraph';
|
||||
import { Text } from '@tiptap/extension-text';
|
||||
|
||||
import { type SlashCommandItem } from '@/advanced-text-editor/extensions/slash-command/SlashCommand';
|
||||
import { SlashCommandRenderer } from '@/advanced-text-editor/extensions/slash-command/SlashCommandRenderer';
|
||||
|
||||
describe('SlashCommandRenderer', () => {
|
||||
let editor: Editor;
|
||||
let mockCommand: jest.Mock;
|
||||
let mockClientRect: () => DOMRect;
|
||||
|
||||
const createMockItems = (): SlashCommandItem[] => [
|
||||
{
|
||||
id: 'test-1',
|
||||
title: 'Test Command 1',
|
||||
description: 'Test description',
|
||||
command: jest.fn(),
|
||||
},
|
||||
{
|
||||
id: 'test-2',
|
||||
title: 'Test Command 2',
|
||||
command: jest.fn(),
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text],
|
||||
content: '<p></p>',
|
||||
});
|
||||
|
||||
mockCommand = jest.fn();
|
||||
mockClientRect = () => new DOMRect(100, 100, 200, 50);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
|
||||
describe('Lifecycle management', () => {
|
||||
it('should create container element and append to body', () => {
|
||||
const renderer = new SlashCommandRenderer({
|
||||
items: createMockItems(),
|
||||
command: mockCommand,
|
||||
clientRect: mockClientRect,
|
||||
editor,
|
||||
range: { from: 0, to: 0 },
|
||||
query: '',
|
||||
});
|
||||
|
||||
expect(renderer.containerElement).toBeInstanceOf(HTMLElement);
|
||||
expect(document.body.contains(renderer.containerElement)).toBe(true);
|
||||
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
it('should create React root on initialization', () => {
|
||||
const renderer = new SlashCommandRenderer({
|
||||
items: createMockItems(),
|
||||
command: mockCommand,
|
||||
clientRect: mockClientRect,
|
||||
editor,
|
||||
range: { from: 0, to: 0 },
|
||||
query: '',
|
||||
});
|
||||
|
||||
expect(renderer.componentRoot).not.toBeNull();
|
||||
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
it('should clean up on destroy', () => {
|
||||
const renderer = new SlashCommandRenderer({
|
||||
items: createMockItems(),
|
||||
command: mockCommand,
|
||||
clientRect: mockClientRect,
|
||||
editor,
|
||||
range: { from: 0, to: 0 },
|
||||
query: '',
|
||||
});
|
||||
|
||||
const containerElement = renderer.containerElement;
|
||||
|
||||
renderer.destroy();
|
||||
|
||||
// After destroy, references should be null
|
||||
expect(renderer.componentRoot).toBeNull();
|
||||
expect(renderer.containerElement).toBeNull();
|
||||
expect(renderer.currentProps).toBeNull();
|
||||
expect(renderer.ref).toBeNull();
|
||||
|
||||
// Container should be removed from DOM
|
||||
expect(document.body.contains(containerElement)).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle multiple destroy calls gracefully', () => {
|
||||
const renderer = new SlashCommandRenderer({
|
||||
items: createMockItems(),
|
||||
command: mockCommand,
|
||||
clientRect: mockClientRect,
|
||||
editor,
|
||||
range: { from: 0, to: 0 },
|
||||
query: '',
|
||||
});
|
||||
|
||||
// First destroy
|
||||
renderer.destroy();
|
||||
|
||||
// Second destroy should not throw
|
||||
expect(() => renderer.destroy()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Props updates', () => {
|
||||
it('should update items when updateProps is called', () => {
|
||||
const renderer = new SlashCommandRenderer({
|
||||
items: createMockItems(),
|
||||
command: mockCommand,
|
||||
clientRect: mockClientRect,
|
||||
editor,
|
||||
range: { from: 0, to: 0 },
|
||||
query: '',
|
||||
});
|
||||
|
||||
const newItems: SlashCommandItem[] = [
|
||||
{
|
||||
id: 'new-item',
|
||||
title: 'New Item',
|
||||
command: jest.fn(),
|
||||
},
|
||||
];
|
||||
|
||||
renderer.updateProps({ items: newItems });
|
||||
|
||||
expect(renderer.currentProps?.items).toEqual(newItems);
|
||||
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
it('should update query when updateProps is called', () => {
|
||||
const renderer = new SlashCommandRenderer({
|
||||
items: createMockItems(),
|
||||
command: mockCommand,
|
||||
clientRect: mockClientRect,
|
||||
editor,
|
||||
range: { from: 0, to: 0 },
|
||||
query: '',
|
||||
});
|
||||
|
||||
renderer.updateProps({ query: 'heading' });
|
||||
|
||||
expect(renderer.currentProps?.query).toBe('heading');
|
||||
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
it('should not update props if component is destroyed', () => {
|
||||
const renderer = new SlashCommandRenderer({
|
||||
items: createMockItems(),
|
||||
command: mockCommand,
|
||||
clientRect: mockClientRect,
|
||||
editor,
|
||||
range: { from: 0, to: 0 },
|
||||
query: '',
|
||||
});
|
||||
|
||||
renderer.destroy();
|
||||
|
||||
// After destroy, updateProps should not throw
|
||||
expect(() => renderer.updateProps({ query: 'test' })).not.toThrow();
|
||||
});
|
||||
|
||||
it('should preserve unmodified props when updating', () => {
|
||||
const originalItems = createMockItems();
|
||||
const renderer = new SlashCommandRenderer({
|
||||
items: originalItems,
|
||||
command: mockCommand,
|
||||
clientRect: mockClientRect,
|
||||
editor,
|
||||
range: { from: 0, to: 0 },
|
||||
query: 'original',
|
||||
});
|
||||
|
||||
// Update only query
|
||||
renderer.updateProps({ query: 'updated' });
|
||||
|
||||
// Items should remain the same
|
||||
expect(renderer.currentProps?.items).toEqual(originalItems);
|
||||
// Query should be updated
|
||||
expect(renderer.currentProps?.query).toBe('updated');
|
||||
|
||||
renderer.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Render method', () => {
|
||||
it('should not throw when render is called', () => {
|
||||
const renderer = new SlashCommandRenderer({
|
||||
items: createMockItems(),
|
||||
command: mockCommand,
|
||||
clientRect: mockClientRect,
|
||||
editor,
|
||||
range: { from: 0, to: 0 },
|
||||
query: '',
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
renderer.render({
|
||||
items: createMockItems(),
|
||||
command: mockCommand,
|
||||
clientRect: mockClientRect,
|
||||
editor,
|
||||
range: { from: 0, to: 0 },
|
||||
query: '',
|
||||
}),
|
||||
).not.toThrow();
|
||||
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
it('should not render if component root is null', () => {
|
||||
const renderer = new SlashCommandRenderer({
|
||||
items: createMockItems(),
|
||||
command: mockCommand,
|
||||
clientRect: mockClientRect,
|
||||
editor,
|
||||
range: { from: 0, to: 0 },
|
||||
query: '',
|
||||
});
|
||||
|
||||
// Destroy to set componentRoot to null
|
||||
renderer.destroy();
|
||||
|
||||
// Render should not throw even with null componentRoot
|
||||
expect(() =>
|
||||
renderer.render({
|
||||
items: createMockItems(),
|
||||
command: mockCommand,
|
||||
clientRect: mockClientRect,
|
||||
editor,
|
||||
range: { from: 0, to: 0 },
|
||||
query: '',
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Ref handling', () => {
|
||||
it('should initialize ref as null', () => {
|
||||
const renderer = new SlashCommandRenderer({
|
||||
items: createMockItems(),
|
||||
command: mockCommand,
|
||||
clientRect: mockClientRect,
|
||||
editor,
|
||||
range: { from: 0, to: 0 },
|
||||
query: '',
|
||||
});
|
||||
|
||||
expect(renderer.ref).toBeNull();
|
||||
|
||||
renderer.destroy();
|
||||
});
|
||||
|
||||
it('should clear ref on destroy', () => {
|
||||
const renderer = new SlashCommandRenderer({
|
||||
items: createMockItems(),
|
||||
command: mockCommand,
|
||||
clientRect: mockClientRect,
|
||||
editor,
|
||||
range: { from: 0, to: 0 },
|
||||
query: '',
|
||||
});
|
||||
|
||||
// Simulate ref being set
|
||||
renderer.ref = { onKeyDown: jest.fn() };
|
||||
|
||||
renderer.destroy();
|
||||
|
||||
expect(renderer.ref).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memory management', () => {
|
||||
it('should not leak DOM elements after destroy', () => {
|
||||
const initialBodyChildCount = document.body.childElementCount;
|
||||
|
||||
const renderer = new SlashCommandRenderer({
|
||||
items: createMockItems(),
|
||||
command: mockCommand,
|
||||
clientRect: mockClientRect,
|
||||
editor,
|
||||
range: { from: 0, to: 0 },
|
||||
query: '',
|
||||
});
|
||||
|
||||
// Should have added one element
|
||||
expect(document.body.childElementCount).toBe(initialBodyChildCount + 1);
|
||||
|
||||
renderer.destroy();
|
||||
|
||||
// Should be back to initial count
|
||||
expect(document.body.childElementCount).toBe(initialBodyChildCount);
|
||||
});
|
||||
|
||||
it('should handle rapid create/destroy cycles', () => {
|
||||
const initialBodyChildCount = document.body.childElementCount;
|
||||
|
||||
// Create and destroy multiple renderers rapidly
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const renderer = new SlashCommandRenderer({
|
||||
items: createMockItems(),
|
||||
command: mockCommand,
|
||||
clientRect: mockClientRect,
|
||||
editor,
|
||||
range: { from: 0, to: 0 },
|
||||
query: '',
|
||||
});
|
||||
renderer.destroy();
|
||||
}
|
||||
|
||||
// Should be back to initial count
|
||||
expect(document.body.childElementCount).toBe(initialBodyChildCount);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user