mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
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:
parent
0876197c8d
commit
6f251a6f8e
72 changed files with 1920 additions and 1043 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -50,3 +50,4 @@ dump.rdb
|
|||
mcp.json
|
||||
/.junie/
|
||||
TRANSLATION_QA_REPORT.md
|
||||
.playwright-mcp/
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritin
|
|||
import { Key } from 'ts-key-enum';
|
||||
import { useDebouncedCallback } from 'use-debounce';
|
||||
|
||||
import { BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
|
||||
import { BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import { ActivityRichTextEditorChangeOnActivityIdEffect } from '@/activities/components/ActivityRichTextEditorChangeOnActivityIdEffect';
|
||||
import { type Attachment } from '@/activities/files/types/Attachment';
|
||||
import { type Note } from '@/activities/types/Note';
|
||||
|
|
@ -27,11 +27,11 @@ import { useIsRecordFieldReadOnly } from '@/object-record/read-only/hooks/useIsR
|
|||
import { isTitleCellInEditModeComponentState } from '@/object-record/record-title-cell/states/isTitleCellInEditModeComponentState';
|
||||
import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType';
|
||||
import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId';
|
||||
import { BlockEditor } from '@/ui/input/editor/components/BlockEditor';
|
||||
import { BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG } from '@/ui/input/editor/constants/BlockEditorGlobalHotkeysConfig';
|
||||
import { useAttachmentSync } from '@/ui/input/editor/hooks/useAttachmentSync';
|
||||
import { parseInitialBlocknote } from '@/ui/input/editor/utils/parseInitialBlocknote';
|
||||
import { prepareBodyWithSignedUrls } from '@/ui/input/editor/utils/prepareBodyWithSignedUrls';
|
||||
import { BlockEditor } from '@/blocknote-editor/components/BlockEditor';
|
||||
import { BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG } from '@/blocknote-editor/constants/BlockEditorGlobalHotkeysConfig';
|
||||
import { useAttachmentSync } from '@/blocknote-editor/hooks/useAttachmentSync';
|
||||
import { parseInitialBlocknote } from '@/blocknote-editor/utils/parseInitialBlocknote';
|
||||
import { prepareBodyWithSignedUrls } from '@/blocknote-editor/utils/prepareBodyWithSignedUrls';
|
||||
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
|
||||
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
|
||||
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
|
||||
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import { useReplaceActivityBlockEditorContent } from '@/activities/hooks/useReplaceActivityBlockEditorContent';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
|
||||
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { useRecoilCallback } from 'recoil';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -57,7 +57,13 @@ const InitialLoadingIndicator = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const MessagePartRenderer = ({ part }: { part: ExtendedUIMessagePart }) => {
|
||||
const MessagePartRenderer = ({
|
||||
part,
|
||||
isStreaming,
|
||||
}: {
|
||||
part: ExtendedUIMessagePart;
|
||||
isStreaming: boolean;
|
||||
}) => {
|
||||
switch (part.type) {
|
||||
case 'reasoning':
|
||||
return (
|
||||
|
|
@ -85,7 +91,7 @@ const MessagePartRenderer = ({ part }: { part: ExtendedUIMessagePart }) => {
|
|||
);
|
||||
default:
|
||||
if (isToolUIPart(part)) {
|
||||
return <ToolStepRenderer toolPart={part} />;
|
||||
return <ToolStepRenderer toolPart={part} isStreaming={isStreaming} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
|
@ -117,7 +123,11 @@ export const AIChatAssistantMessageRenderer = ({
|
|||
<div>
|
||||
<StyledMessagePartsContainer>
|
||||
{filteredParts.map((part, index) => (
|
||||
<MessagePartRenderer key={index} part={part} />
|
||||
<MessagePartRenderer
|
||||
key={index}
|
||||
part={part}
|
||||
isStreaming={isLastMessageStreaming}
|
||||
/>
|
||||
))}
|
||||
</StyledMessagePartsContainer>
|
||||
{isLastMessageStreaming && !hasError && <StyledStreamingIndicator />}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import styled from '@emotion/styled';
|
||||
import { type Editor } from '@tiptap/react';
|
||||
|
||||
import { AIChatSuggestedPrompts } from '@/ai/components/suggested-prompts/AIChatSuggestedPrompts';
|
||||
|
||||
|
|
@ -10,10 +11,14 @@ const StyledEmptyState = styled.div`
|
|||
height: 100%;
|
||||
`;
|
||||
|
||||
export const AIChatEmptyState = () => {
|
||||
type AIChatEmptyStateProps = {
|
||||
editor: Editor | null;
|
||||
};
|
||||
|
||||
export const AIChatEmptyState = ({ editor }: AIChatEmptyStateProps) => {
|
||||
return (
|
||||
<StyledEmptyState>
|
||||
<AIChatSuggestedPrompts />
|
||||
<AIChatSuggestedPrompts editor={editor} />
|
||||
</StyledEmptyState>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { TextArea } from '@/ui/input/components/TextArea';
|
||||
import styled from '@emotion/styled';
|
||||
import { EditorContent } from '@tiptap/react';
|
||||
import { IconHistory } from 'twenty-ui/display';
|
||||
import { IconButton } from 'twenty-ui/input';
|
||||
|
||||
|
|
@ -17,15 +17,13 @@ import { AIChatSkeletonLoader } from '@/ai/components/internal/AIChatSkeletonLoa
|
|||
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 { useAIChatEditor } from '@/ai/hooks/useAIChatEditor';
|
||||
import { useAIChatFileUpload } from '@/ai/hooks/useAIChatFileUpload';
|
||||
import { useAgentChatContextOrThrow } from '@/ai/hooks/useAgentChatContextOrThrow';
|
||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
|
||||
const StyledContainer = styled.div<{ isDraggingFile: boolean }>`
|
||||
background: ${({ theme }) => theme.background.primary};
|
||||
|
|
@ -65,25 +63,39 @@ const StyledInputBox = styled.div`
|
|||
}
|
||||
`;
|
||||
|
||||
const StyledTextAreaWrapper = styled.div`
|
||||
const StyledEditorWrapper = styled.div`
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const StyledChatTextArea = styled(TextArea)`
|
||||
&& {
|
||||
.tiptap {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
color: ${({ theme }) => theme.font.color.primary};
|
||||
font-family: inherit;
|
||||
font-size: ${({ theme }) => theme.font.size.md};
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
line-height: 16px;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
}
|
||||
min-height: 48px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
|
||||
&&:focus {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p.is-editor-empty:first-of-type::before {
|
||||
color: ${({ theme }) => theme.font.color.light};
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
font-weight: ${({ theme }) => theme.font.weight.regular};
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
@ -108,15 +120,16 @@ const StyledButtonsContainer = styled.div`
|
|||
export const AIChatTab = () => {
|
||||
const [isDraggingFile, setIsDraggingFile] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const { isLoading, messages, isStreaming, error } =
|
||||
const { isLoading, messages, isStreaming, error, handleSendMessage } =
|
||||
useAgentChatContextOrThrow();
|
||||
|
||||
const [agentChatInput, setAgentChatInput] =
|
||||
useRecoilState(agentChatInputState);
|
||||
|
||||
const { uploadFiles } = useAIChatFileUpload();
|
||||
const { navigateCommandMenu } = useCommandMenu();
|
||||
|
||||
const { editor, handleSendAndClear } = useAIChatEditor({
|
||||
onSendMessage: handleSendMessage,
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledContainer
|
||||
isDraggingFile={isDraggingFile}
|
||||
|
|
@ -158,7 +171,7 @@ export const AIChatTab = () => {
|
|||
</StyledScrollWrapper>
|
||||
)}
|
||||
{messages.length === 0 && !error && !isLoading && (
|
||||
<AIChatEmptyState />
|
||||
<AIChatEmptyState editor={editor} />
|
||||
)}
|
||||
{messages.length === 0 && error && !isLoading && (
|
||||
<AIChatStandaloneError error={error} />
|
||||
|
|
@ -168,16 +181,9 @@ export const AIChatTab = () => {
|
|||
<StyledInputArea isMobile={isMobile}>
|
||||
<AgentChatContextPreview />
|
||||
<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>
|
||||
<StyledEditorWrapper>
|
||||
<EditorContent editor={editor} />
|
||||
</StyledEditorWrapper>
|
||||
<StyledButtonsContainer>
|
||||
<AIChatContextUsageButton />
|
||||
<IconButton
|
||||
|
|
@ -194,7 +200,7 @@ export const AIChatTab = () => {
|
|||
ariaLabel={t`View Previous AI Chats`}
|
||||
/>
|
||||
<AgentChatFileUploadButton />
|
||||
<SendMessageButton />
|
||||
<SendMessageButton onSend={handleSendAndClear} />
|
||||
</StyledButtonsContainer>
|
||||
</StyledInputBox>
|
||||
</StyledInputArea>
|
||||
|
|
|
|||
|
|
@ -102,23 +102,10 @@ const MarkdownRenderer = lazy(async () => {
|
|||
};
|
||||
});
|
||||
|
||||
const StyledTableScrollContainer = styled.div`
|
||||
overflow-x: auto;
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
margin-block: ${({ theme }) => theme.spacing(2)};
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: ${({ theme }) => `1px solid ${theme.border.color.light}`};
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: ${({ theme }) => theme.background.secondary};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
const StyledMarkdownContainer = styled.div`
|
||||
img {
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
|
|
@ -142,6 +129,26 @@ const StyledSkeletonContainer = styled.div`
|
|||
width: 100%;
|
||||
`;
|
||||
|
||||
const StyledTableScrollContainer = styled.div`
|
||||
overflow-x: auto;
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
margin-block: ${({ theme }) => theme.spacing(2)};
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
border: ${({ theme }) => `1px solid ${theme.border.color.light}`};
|
||||
padding: ${({ theme }) => theme.spacing(2)};
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: ${({ theme }) => theme.background.secondary};
|
||||
font-weight: ${({ theme }) => theme.font.weight.medium};
|
||||
}
|
||||
`;
|
||||
|
||||
const LoadingSkeleton = () => {
|
||||
const theme = useTheme();
|
||||
|
||||
|
|
@ -179,13 +186,15 @@ const LoadingSkeleton = () => {
|
|||
|
||||
export const LazyMarkdownRenderer = ({ text }: { text: string }) => {
|
||||
return (
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<MarkdownRenderer
|
||||
TableScrollContainer={StyledTableScrollContainer}
|
||||
StyledParagraph={StyledParagraph}
|
||||
>
|
||||
{text}
|
||||
</MarkdownRenderer>
|
||||
</Suspense>
|
||||
<StyledMarkdownContainer>
|
||||
<Suspense fallback={<LoadingSkeleton />}>
|
||||
<MarkdownRenderer
|
||||
TableScrollContainer={StyledTableScrollContainer}
|
||||
StyledParagraph={StyledParagraph}
|
||||
>
|
||||
{text}
|
||||
</MarkdownRenderer>
|
||||
</Suspense>
|
||||
</StyledMarkdownContainer>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -123,7 +123,13 @@ const StyledTab = styled.div<{ isActive: boolean }>`
|
|||
|
||||
type TabType = 'output' | 'input';
|
||||
|
||||
export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
|
||||
export const ToolStepRenderer = ({
|
||||
toolPart,
|
||||
isStreaming,
|
||||
}: {
|
||||
toolPart: ToolUIPart;
|
||||
isStreaming: boolean;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
const theme = useTheme();
|
||||
const { copyToClipboard } = useCopyToClipboard();
|
||||
|
|
@ -151,7 +157,7 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
|
|||
};
|
||||
} | null;
|
||||
|
||||
const isRunning = !output && !hasError;
|
||||
const isRunning = !output && !hasError && isStreaming;
|
||||
|
||||
return (
|
||||
<CodeExecutionDisplay
|
||||
|
|
@ -166,17 +172,23 @@ export const ToolStepRenderer = ({ toolPart }: { toolPart: ToolUIPart }) => {
|
|||
}
|
||||
|
||||
if (!output && !hasError) {
|
||||
const displayText = isStreaming
|
||||
? getToolDisplayMessage(input, rawToolName, false)
|
||||
: getToolDisplayMessage(input, rawToolName, true);
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledToggleButton isExpandable={false}>
|
||||
<StyledLeftContent>
|
||||
<StyledIconTextContainer>
|
||||
<ToolIcon size={theme.icon.size.sm} />
|
||||
<ShimmeringText>
|
||||
<StyledDisplayMessage>
|
||||
{getToolDisplayMessage(input, rawToolName, false)}
|
||||
</StyledDisplayMessage>
|
||||
</ShimmeringText>
|
||||
{isStreaming ? (
|
||||
<ShimmeringText>
|
||||
<StyledDisplayMessage>{displayText}</StyledDisplayMessage>
|
||||
</ShimmeringText>
|
||||
) : (
|
||||
<StyledDisplayMessage>{displayText}</StyledDisplayMessage>
|
||||
)}
|
||||
</StyledIconTextContainer>
|
||||
</StyledLeftContent>
|
||||
<StyledRightContent>
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ export const AIChatContextUsageButton = () => {
|
|||
{isHovered && (
|
||||
<StyledHoverCard>
|
||||
<StyledSection>
|
||||
<StyledSectionTitle>{t`Context window`}</StyledSectionTitle>
|
||||
<StyledRow>
|
||||
<StyledPercentage>{formattedPercentage}%</StyledPercentage>
|
||||
<StyledValue>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,16 @@
|
|||
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 { useRecoilValue } from 'recoil';
|
||||
import { Key } from 'ts-key-enum';
|
||||
import { IconArrowUp, IconPlayerStop } from 'twenty-ui/display';
|
||||
import { RoundedIconButton } from 'twenty-ui/input';
|
||||
|
||||
export const SendMessageButton = () => {
|
||||
const agentChatInput = useRecoilValue(agentChatInputState);
|
||||
const { handleSendMessage, handleStop, isLoading, isStreaming } =
|
||||
useAgentChatContextOrThrow();
|
||||
type SendMessageButtonProps = {
|
||||
onSend: () => void;
|
||||
};
|
||||
|
||||
useHotkeysOnFocusedElement({
|
||||
keys: [Key.Enter],
|
||||
callback: (event: KeyboardEvent) => {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
event.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
},
|
||||
focusId: AI_CHAT_INPUT_ID,
|
||||
dependencies: [agentChatInput, isLoading],
|
||||
options: {
|
||||
enableOnFormTags: true,
|
||||
},
|
||||
});
|
||||
export const SendMessageButton = ({ onSend }: SendMessageButtonProps) => {
|
||||
const agentChatInput = useRecoilValue(agentChatInputState);
|
||||
const { handleStop, isLoading, isStreaming } = useAgentChatContextOrThrow();
|
||||
|
||||
if (isStreaming) {
|
||||
return (
|
||||
|
|
@ -41,7 +26,7 @@ export const SendMessageButton = () => {
|
|||
<RoundedIconButton
|
||||
Icon={IconArrowUp}
|
||||
size="medium"
|
||||
onClick={() => handleSendMessage()}
|
||||
onClick={onSend}
|
||||
disabled={!agentChatInput || isLoading}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import styled from '@emotion/styled';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { type Editor } from '@tiptap/react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { LightButton } from 'twenty-ui/input';
|
||||
|
||||
|
|
@ -31,14 +32,26 @@ const StyledSuggestedPromptButton = styled(LightButton)`
|
|||
const pickRandom = <T,>(items: T[]): T =>
|
||||
items[Math.floor(Math.random() * items.length)];
|
||||
|
||||
export const AIChatSuggestedPrompts = () => {
|
||||
type AIChatSuggestedPromptsProps = {
|
||||
editor: Editor | null;
|
||||
};
|
||||
|
||||
export const AIChatSuggestedPrompts = ({
|
||||
editor,
|
||||
}: AIChatSuggestedPromptsProps) => {
|
||||
const { t: resolveMessage } = useLingui();
|
||||
const setAgentChatInput = useSetRecoilState(agentChatInputState);
|
||||
|
||||
const handleClick = (prompt: SuggestedPrompt) => {
|
||||
const picked = pickRandom(prompt.prefillPrompts);
|
||||
const text = resolveMessage(picked);
|
||||
|
||||
setAgentChatInput(resolveMessage(picked));
|
||||
setAgentChatInput(text);
|
||||
editor?.commands.setContent({
|
||||
type: 'doc',
|
||||
content: [{ type: 'paragraph', content: [{ type: 'text', text }] }],
|
||||
});
|
||||
editor?.commands.focus('end');
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
116
packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts
Normal file
116
packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { t } from '@lingui/core/macro';
|
||||
import { Document } from '@tiptap/extension-document';
|
||||
import { HardBreak } from '@tiptap/extension-hard-break';
|
||||
import { Paragraph } from '@tiptap/extension-paragraph';
|
||||
import { Text } from '@tiptap/extension-text';
|
||||
import { Placeholder } from '@tiptap/extensions';
|
||||
import { useEditor } from '@tiptap/react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { AI_CHAT_INPUT_ID } from '@/ai/constants/AiChatInputId';
|
||||
import { agentChatInputState } from '@/ai/states/agentChatInputState';
|
||||
import { MENTION_SUGGESTION_PLUGIN_KEY } from '@/mention/constants/MentionSuggestionPluginKey';
|
||||
import { MentionSuggestion } from '@/mention/extensions/MentionSuggestion';
|
||||
import { MentionTag } from '@/mention/extensions/MentionTag';
|
||||
import { useMentionSearch } from '@/mention/hooks/useMentionSearch';
|
||||
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
|
||||
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
|
||||
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
||||
import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly';
|
||||
|
||||
type UseAIChatEditorProps = {
|
||||
onSendMessage: () => void;
|
||||
};
|
||||
|
||||
export const useAIChatEditor = ({ onSendMessage }: UseAIChatEditorProps) => {
|
||||
const setAgentChatInput = useSetRecoilState(agentChatInputState);
|
||||
const { searchMentionRecords } = useMentionSearch();
|
||||
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
|
||||
const { removeFocusItemFromFocusStackById } =
|
||||
useRemoveFocusItemFromFocusStackById();
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Placeholder.configure({
|
||||
placeholder: t`Ask, search or make anything...`,
|
||||
}),
|
||||
HardBreak.configure({
|
||||
keepMarks: false,
|
||||
}),
|
||||
MentionTag,
|
||||
MentionSuggestion,
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
editorProps: {
|
||||
handleKeyDown: (view, event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
const suggestionState = MENTION_SUGGESTION_PLUGIN_KEY.getState(
|
||||
view.state,
|
||||
);
|
||||
if (suggestionState?.active === true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
onSendMessage();
|
||||
|
||||
const { state } = view;
|
||||
view.dispatch(state.tr.delete(0, state.doc.content.size));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: currentEditor }) => {
|
||||
const text = turnIntoEmptyStringIfWhitespacesOnly(
|
||||
currentEditor.getText({ blockSeparator: '\n' }),
|
||||
);
|
||||
setAgentChatInput(text);
|
||||
},
|
||||
onFocus: () => {
|
||||
pushFocusItemToFocusStack({
|
||||
focusId: AI_CHAT_INPUT_ID,
|
||||
component: {
|
||||
type: FocusComponentType.TEXT_AREA,
|
||||
instanceId: AI_CHAT_INPUT_ID,
|
||||
},
|
||||
globalHotkeysConfig: {
|
||||
enableGlobalHotkeysConflictingWithKeyboard: false,
|
||||
},
|
||||
});
|
||||
},
|
||||
onBlur: () => {
|
||||
removeFocusItemFromFocusStackById({ focusId: AI_CHAT_INPUT_ID });
|
||||
},
|
||||
injectCSS: false,
|
||||
});
|
||||
|
||||
// Keep search function in sync via Tiptap extension storage,
|
||||
// avoiding stale closures without useRef
|
||||
if (isDefined(editor)) {
|
||||
const storage = editor.extensionStorage as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const mentionStorage = storage['mention-suggestion'] as {
|
||||
searchMentionRecords: typeof searchMentionRecords;
|
||||
};
|
||||
mentionStorage.searchMentionRecords = searchMentionRecords;
|
||||
}
|
||||
|
||||
const handleSendAndClear = useCallback(() => {
|
||||
onSendMessage();
|
||||
editor?.commands.clearContent();
|
||||
}, [onSendMessage, editor]);
|
||||
|
||||
return { editor, handleSendAndClear };
|
||||
};
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import { i18n } from '@lingui/core';
|
||||
|
||||
import {
|
||||
getToolDisplayMessage,
|
||||
resolveToolInput,
|
||||
} from '@/ai/utils/getToolDisplayMessage';
|
||||
|
||||
beforeEach(() => {
|
||||
i18n.load('en', {});
|
||||
i18n.activate('en');
|
||||
});
|
||||
|
||||
describe('resolveToolInput', () => {
|
||||
it('should pass through non-execute_tool inputs unchanged', () => {
|
||||
const input = { query: 'test' };
|
||||
const result = resolveToolInput(input, 'web_search');
|
||||
|
||||
expect(result).toEqual({
|
||||
resolvedInput: input,
|
||||
resolvedToolName: 'web_search',
|
||||
});
|
||||
});
|
||||
|
||||
it('should unwrap execute_tool input', () => {
|
||||
const input = {
|
||||
toolName: 'find_companies',
|
||||
arguments: { filter: { name: 'Acme' } },
|
||||
};
|
||||
const result = resolveToolInput(input, 'execute_tool');
|
||||
|
||||
expect(result).toEqual({
|
||||
resolvedInput: { filter: { name: 'Acme' } },
|
||||
resolvedToolName: 'find_companies',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return original input for non-execute_tool even with toolName field', () => {
|
||||
const input = { toolName: 'inner', arguments: {} };
|
||||
const result = resolveToolInput(input, 'web_search');
|
||||
|
||||
expect(result).toEqual({
|
||||
resolvedInput: input,
|
||||
resolvedToolName: 'web_search',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolDisplayMessage', () => {
|
||||
describe('web_search', () => {
|
||||
it('should show finished message with query', () => {
|
||||
const message = getToolDisplayMessage(
|
||||
{ query: 'CRM tools' },
|
||||
'web_search',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(message).toContain('Searched');
|
||||
expect(message).toContain('CRM tools');
|
||||
});
|
||||
|
||||
it('should show in-progress message with query', () => {
|
||||
const message = getToolDisplayMessage(
|
||||
{ query: 'CRM tools' },
|
||||
'web_search',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(message).toContain('Searching');
|
||||
expect(message).toContain('CRM tools');
|
||||
});
|
||||
|
||||
it('should handle nested query format', () => {
|
||||
const message = getToolDisplayMessage(
|
||||
{ action: { query: 'nested query' } },
|
||||
'web_search',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(message).toContain('nested query');
|
||||
});
|
||||
|
||||
it('should handle missing query', () => {
|
||||
const message = getToolDisplayMessage({}, 'web_search', true);
|
||||
|
||||
expect(message).toContain('Searched the web');
|
||||
});
|
||||
});
|
||||
|
||||
describe('learn_tools', () => {
|
||||
it('should show tool names when provided', () => {
|
||||
const message = getToolDisplayMessage(
|
||||
{ toolNames: ['find_companies', 'create_task'] },
|
||||
'learn_tools',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(message).toContain('Learned');
|
||||
expect(message).toContain('find_companies, create_task');
|
||||
});
|
||||
|
||||
it('should show generic message without tool names', () => {
|
||||
const message = getToolDisplayMessage({}, 'learn_tools', true);
|
||||
|
||||
expect(message).toContain('Learned tools');
|
||||
});
|
||||
});
|
||||
|
||||
describe('load_skills', () => {
|
||||
it('should show skill names when provided', () => {
|
||||
const message = getToolDisplayMessage(
|
||||
{ skillNames: ['data-manipulation'] },
|
||||
'load_skills',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(message).toContain('Loading');
|
||||
expect(message).toContain('data-manipulation');
|
||||
});
|
||||
|
||||
it('should show generic message without skill names', () => {
|
||||
const message = getToolDisplayMessage({}, 'load_skills', true);
|
||||
|
||||
expect(message).toContain('Loaded skills');
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom loading message', () => {
|
||||
it('should use loadingMessage when provided', () => {
|
||||
const message = getToolDisplayMessage(
|
||||
{ loadingMessage: 'Building dashboard...' },
|
||||
'some_tool',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(message).toBe('Building dashboard...');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generic tools', () => {
|
||||
it('should format tool name with spaces for finished state', () => {
|
||||
const message = getToolDisplayMessage(
|
||||
{},
|
||||
'create_complete_dashboard',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(message).toContain('Ran');
|
||||
expect(message).toContain('create complete dashboard');
|
||||
});
|
||||
|
||||
it('should format tool name with spaces for in-progress state', () => {
|
||||
const message = getToolDisplayMessage(
|
||||
{},
|
||||
'create_complete_dashboard',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(message).toContain('Running');
|
||||
expect(message).toContain('create complete dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute_tool wrapper', () => {
|
||||
it('should unwrap execute_tool and display inner tool name', () => {
|
||||
const message = getToolDisplayMessage(
|
||||
{ toolName: 'find_companies', arguments: { limit: 10 } },
|
||||
'execute_tool',
|
||||
true,
|
||||
);
|
||||
|
||||
expect(message).toContain('Ran');
|
||||
expect(message).toContain('find companies');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -91,8 +91,8 @@ export const getToolDisplayMessage = (
|
|||
|
||||
if (isNonEmptyString(query)) {
|
||||
return byStatus(
|
||||
t`Searched the web for '${query}'`,
|
||||
t`Searching the web for '${query}'`,
|
||||
t`Searched the web for ${query}`,
|
||||
t`Searching the web for ${query}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { MentionRecordChip } from '@/mention/components/MentionRecordChip';
|
||||
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
|
||||
import { RecordChip } from '@/object-record/components/RecordChip';
|
||||
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
|
||||
|
|
@ -15,7 +16,14 @@ const StyledRecordChip = styled(RecordChip)`
|
|||
padding: ${({ theme }) => `0 ${theme.spacing(1)}`};
|
||||
`;
|
||||
|
||||
const MentionInlineContentRenderer = ({
|
||||
const StyledInlineMentionRecordChip = styled(MentionRecordChip)`
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: ${({ theme }) => `0 ${theme.spacing(1)}`};
|
||||
`;
|
||||
|
||||
// Backward-compatible renderer for legacy notes that only stored objectMetadataId + recordId
|
||||
const LegacyMentionRenderer = ({
|
||||
recordId,
|
||||
objectMetadataId,
|
||||
}: {
|
||||
|
|
@ -79,15 +87,43 @@ export const MentionInlineContent = createReactInlineContentSpec(
|
|||
objectMetadataId: {
|
||||
default: '' as const,
|
||||
},
|
||||
objectNameSingular: {
|
||||
default: '' as const,
|
||||
},
|
||||
label: {
|
||||
default: '' as const,
|
||||
},
|
||||
imageUrl: {
|
||||
default: '' as const,
|
||||
},
|
||||
},
|
||||
content: 'none',
|
||||
},
|
||||
{
|
||||
render: (props) => {
|
||||
const { recordId, objectMetadataId } = props.inlineContent.props;
|
||||
const {
|
||||
recordId,
|
||||
objectMetadataId,
|
||||
objectNameSingular,
|
||||
label,
|
||||
imageUrl,
|
||||
} = props.inlineContent.props;
|
||||
|
||||
// New notes store objectNameSingular + label + imageUrl directly
|
||||
if (isNonEmptyString(objectNameSingular) && isNonEmptyString(label)) {
|
||||
return (
|
||||
<StyledInlineMentionRecordChip
|
||||
recordId={recordId}
|
||||
objectNameSingular={objectNameSingular}
|
||||
label={label}
|
||||
imageUrl={imageUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy notes only have objectMetadataId + recordId: fetch data
|
||||
return (
|
||||
<MentionInlineContentRenderer
|
||||
<LegacyMentionRenderer
|
||||
recordId={recordId}
|
||||
objectMetadataId={objectMetadataId}
|
||||
/>
|
||||
|
|
@ -4,8 +4,8 @@ import {
|
|||
defaultInlineContentSpecs,
|
||||
} from '@blocknote/core';
|
||||
|
||||
import { FileBlock } from '@/activities/blocks/components/FileBlock';
|
||||
import { MentionInlineContent } from '@/activities/blocks/components/MentionInlineContent';
|
||||
import { FileBlock } from '@/blocknote-editor/blocks/FileBlock';
|
||||
import { MentionInlineContent } from '@/blocknote-editor/blocks/MentionInlineContent';
|
||||
|
||||
export const BLOCK_SCHEMA = BlockNoteSchema.create({
|
||||
blockSpecs: {
|
||||
|
|
@ -5,15 +5,15 @@ 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 { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import { getSlashMenu } from '@/blocknote-editor/utils/getSlashMenu';
|
||||
import { CustomMentionMenu } from '@/blocknote-editor/components/CustomMentionMenu';
|
||||
import { CustomSideMenu } from '@/blocknote-editor/components/CustomSideMenu';
|
||||
import {
|
||||
CustomSlashMenu,
|
||||
type SuggestionItem,
|
||||
} from '@/ui/input/editor/components/CustomSlashMenu';
|
||||
import { useMentionMenu } from '@/ui/input/editor/hooks/useMentionMenu';
|
||||
} from '@/blocknote-editor/components/CustomSlashMenu';
|
||||
import { useMentionMenu } from '@/mention/hooks/useMentionMenu';
|
||||
|
||||
interface BlockEditorProps {
|
||||
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
|
||||
import { isSlashMenuOpenComponentState } from '@/ui/input/editor/states/isSlashMenuOpenComponentState';
|
||||
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import { isSlashMenuOpenComponentState } from '@/blocknote-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';
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
|
||||
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
|
||||
import { useComponentsContext } from '@blocknote/react';
|
||||
|
||||
|
|
@ -5,17 +5,14 @@ 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 { MentionMenuListItem } from '@/mention/components/MentionMenuListItem';
|
||||
import {
|
||||
type CustomMentionMenuProps,
|
||||
type MentionItem,
|
||||
} from '@/ui/input/editor/components/types';
|
||||
} from '@/blocknote-editor/types/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 };
|
||||
|
|
@ -54,10 +51,6 @@ export const CustomMentionMenu = ({
|
|||
|
||||
return (
|
||||
<StyledContainer ref={refs.setReference}>
|
||||
<CustomMentionMenuSelectedIndexSyncEffect
|
||||
items={filteredItems}
|
||||
selectedIndex={selectedIndex}
|
||||
/>
|
||||
<>
|
||||
{createPortal(
|
||||
<motion.div
|
||||
|
|
@ -73,22 +66,18 @@ export const CustomMentionMenu = ({
|
|||
>
|
||||
<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>
|
||||
{filteredItems.map((item, index) => (
|
||||
<MentionMenuListItem
|
||||
key={item.recordId!}
|
||||
recordId={item.recordId!}
|
||||
objectNameSingular={item.objectNameSingular!}
|
||||
label={item.label ?? item.title}
|
||||
imageUrl={item.imageUrl ?? ''}
|
||||
objectLabelSingular={item.objectLabelSingular ?? ''}
|
||||
isSelected={index === selectedIndex}
|
||||
onClick={() => onItemClick?.(item)}
|
||||
/>
|
||||
))}
|
||||
</DropdownMenuItemsContainer>
|
||||
</DropdownContent>
|
||||
</OverlayContainer>
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
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 { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import { CustomAddBlockItem } from '@/blocknote-editor/components/CustomAddBlockItem';
|
||||
import { CustomSideMenuOptions } from '@/blocknote-editor/components/CustomSideMenuOptions';
|
||||
import {
|
||||
BlockColorsItem,
|
||||
DragHandleButton,
|
||||
|
|
@ -6,12 +6,12 @@ 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 { CustomSlashMenuListItem } from '@/blocknote-editor/components/CustomSlashMenuListItem';
|
||||
import { CustomSlashMenuSelectedIndexSyncEffect } from '@/blocknote-editor/components/CustomSlashMenuSelectedIndexSyncEffect';
|
||||
import type {
|
||||
CustomSlashMenuProps,
|
||||
SuggestionItem,
|
||||
} from '@/ui/input/editor/components/types';
|
||||
} from '@/blocknote-editor/types/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';
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { SLASH_MENU_LIST_ID } from '@/ui/input/constants/SlashMenuListId';
|
||||
import { type SuggestionItem } from '@/ui/input/editor/components/types';
|
||||
import { type SuggestionItem } from '@/blocknote-editor/types/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';
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { SLASH_MENU_LIST_ID } from '@/ui/input/constants/SlashMenuListId';
|
||||
import { type SuggestionItem } from '@/ui/input/editor/components/types';
|
||||
import { type SuggestionItem } from '@/blocknote-editor/types/types';
|
||||
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
|
||||
import { useEffect } from 'react';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { BlockEditorComponentInstanceContext } from '@/ui/input/editor/contexts/BlockEditorCompoponeInstanceContext';
|
||||
import { BlockEditorComponentInstanceContext } from '@/blocknote-editor/contexts/BlockEditorCompoponeInstanceContext';
|
||||
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
|
||||
|
||||
export const isSlashMenuOpenComponentState = createComponentState<boolean>({
|
||||
|
|
@ -15,6 +15,9 @@ export type MentionItem = DefaultReactSuggestionItem & {
|
|||
recordId?: string;
|
||||
objectNameSingular?: string;
|
||||
objectMetadataId?: string;
|
||||
label?: string;
|
||||
imageUrl?: string;
|
||||
objectLabelSingular?: string;
|
||||
};
|
||||
|
||||
export type CustomMentionMenuProps = SuggestionMenuProps<MentionItem>;
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { PartialBlock } from '@blocknote/core';
|
||||
import { getFirstNonEmptyLineOfRichText } from '@/ui/input/editor/utils/getFirstNonEmptyLineOfRichText';
|
||||
import { getFirstNonEmptyLineOfRichText } from '@/blocknote-editor/utils/getFirstNonEmptyLineOfRichText';
|
||||
|
||||
describe('getFirstNonEmptyLineOfRichText', () => {
|
||||
it('should return an empty string if the input is null', () => {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { parseInitialBlocknote } from '@/ui/input/editor/utils/parseInitialBlocknote';
|
||||
import { parseInitialBlocknote } from '@/blocknote-editor/utils/parseInitialBlocknote';
|
||||
|
||||
describe('parseInitialBlocknote', () => {
|
||||
it('should parse valid JSON array string', () => {
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { prepareBodyWithSignedUrls } from '@/ui/input/editor/utils/prepareBodyWithSignedUrls';
|
||||
import { prepareBodyWithSignedUrls } from '@/blocknote-editor/utils/prepareBodyWithSignedUrls';
|
||||
|
||||
describe('prepareBodyWithSignedUrls', () => {
|
||||
it('should return empty string as-is', () => {
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { getDefaultReactSlashMenuItems } from '@blocknote/react';
|
||||
|
||||
import { type SuggestionItem } from '@/ui/input/editor/components/CustomSlashMenu';
|
||||
import { type SuggestionItem } from '@/blocknote-editor/components/CustomSlashMenu';
|
||||
|
||||
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
|
||||
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import {
|
||||
IconBlockquote,
|
||||
IconCode,
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { type NodeViewProps } from '@tiptap/core';
|
||||
import { NodeViewWrapper } from '@tiptap/react';
|
||||
|
||||
import { MentionRecordChip } from '@/mention/components/MentionRecordChip';
|
||||
|
||||
type MentionChipProps = Pick<NodeViewProps, 'node'>;
|
||||
|
||||
export const MentionChip = ({ node }: MentionChipProps) => {
|
||||
const recordId = node.attrs.recordId as string;
|
||||
const objectNameSingular = node.attrs.objectNameSingular as string;
|
||||
const label = node.attrs.label as string;
|
||||
const imageUrl = (node.attrs.imageUrl as string) ?? '';
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="span" style={{ display: 'inline' }}>
|
||||
<MentionRecordChip
|
||||
recordId={recordId}
|
||||
objectNameSingular={objectNameSingular}
|
||||
label={label}
|
||||
imageUrl={imageUrl}
|
||||
/>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { type MouseEvent } from 'react';
|
||||
|
||||
import { getAvatarType } from '@/object-metadata/utils/getAvatarType';
|
||||
import { Avatar } from 'twenty-ui/display';
|
||||
import { MenuItemSuggestion } from 'twenty-ui/navigation';
|
||||
|
||||
type MentionMenuListItemProps = {
|
||||
recordId: string;
|
||||
objectNameSingular: string;
|
||||
label: string;
|
||||
imageUrl: string;
|
||||
objectLabelSingular: string;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
export const MentionMenuListItem = ({
|
||||
recordId,
|
||||
objectNameSingular,
|
||||
label,
|
||||
imageUrl,
|
||||
objectLabelSingular,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: MentionMenuListItemProps) => {
|
||||
const handleClick = (event?: MouseEvent) => {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuItemSuggestion
|
||||
selected={isSelected}
|
||||
onClick={handleClick}
|
||||
text={label}
|
||||
contextualText={objectLabelSingular}
|
||||
contextualTextPosition="left"
|
||||
LeftIcon={() => (
|
||||
<Avatar
|
||||
placeholder={label}
|
||||
placeholderColorSeed={recordId}
|
||||
avatarUrl={imageUrl}
|
||||
type={getAvatarType(objectNameSingular) ?? 'rounded'}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { AvatarChip, Chip, ChipVariant, LinkChip } from 'twenty-ui/components';
|
||||
|
||||
type MentionRecordChipProps = {
|
||||
recordId: string;
|
||||
objectNameSingular: string;
|
||||
label: string;
|
||||
imageUrl: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const MentionRecordChip = ({
|
||||
recordId,
|
||||
objectNameSingular,
|
||||
label,
|
||||
imageUrl,
|
||||
className,
|
||||
}: MentionRecordChipProps) => {
|
||||
if (!isNonEmptyString(objectNameSingular)) {
|
||||
return (
|
||||
<Chip
|
||||
label={t`Unknown object`}
|
||||
variant={ChipVariant.Transparent}
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNonEmptyString(recordId)) {
|
||||
return (
|
||||
<Chip
|
||||
label={t`Deleted record`}
|
||||
variant={ChipVariant.Transparent}
|
||||
disabled
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const linkToShowPage = getLinkToShowPage(objectNameSingular, {
|
||||
id: recordId,
|
||||
});
|
||||
|
||||
return (
|
||||
<LinkChip
|
||||
label={label}
|
||||
emptyLabel={t`Untitled`}
|
||||
to={linkToShowPage}
|
||||
variant={ChipVariant.Highlighted}
|
||||
className={className}
|
||||
leftComponent={
|
||||
<AvatarChip
|
||||
placeholder={label}
|
||||
placeholderColorSeed={recordId}
|
||||
avatarType="rounded"
|
||||
avatarUrl={imageUrl}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { forwardRef } from 'react';
|
||||
import { AvatarChip } from 'twenty-ui/components';
|
||||
import { MenuItemSuggestion } from 'twenty-ui/navigation';
|
||||
|
||||
import type { MentionSearchResult } from '@/mention/types/MentionSearchResult';
|
||||
import type { MentionSuggestionMenuProps } from '@/mention/types/MentionSuggestionMenuProps';
|
||||
import { SuggestionMenu } from '@/ui/suggestion/components/SuggestionMenu';
|
||||
|
||||
const getItemKey = (item: MentionSearchResult) =>
|
||||
`${item.objectNameSingular}-${item.recordId}`;
|
||||
|
||||
const renderItem = (
|
||||
item: MentionSearchResult,
|
||||
isSelected: boolean,
|
||||
onSelect: (item: MentionSearchResult) => void,
|
||||
) => (
|
||||
<MenuItemSuggestion
|
||||
LeftIcon={() => (
|
||||
<AvatarChip
|
||||
placeholder={item.label}
|
||||
placeholderColorSeed={item.recordId}
|
||||
avatarType="rounded"
|
||||
avatarUrl={item.imageUrl}
|
||||
/>
|
||||
)}
|
||||
text={item.label}
|
||||
contextualText={item.objectLabelSingular}
|
||||
selected={isSelected}
|
||||
onClick={() => {
|
||||
onSelect(item);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const MentionSuggestionMenu = forwardRef<
|
||||
unknown,
|
||||
MentionSuggestionMenuProps
|
||||
>((props, ref) => {
|
||||
const { items, onSelect, editor, range } = props;
|
||||
|
||||
return (
|
||||
<SuggestionMenu
|
||||
ref={ref}
|
||||
items={items}
|
||||
onSelect={onSelect}
|
||||
editor={editor}
|
||||
range={range}
|
||||
getItemKey={getItemKey}
|
||||
renderItem={(item, isSelected) => renderItem(item, isSelected, onSelect)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import { PluginKey } from '@tiptap/pm/state';
|
||||
|
||||
export const MENTION_SUGGESTION_PLUGIN_KEY = new PluginKey(
|
||||
'mention-suggestion',
|
||||
);
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { Extension } from '@tiptap/core';
|
||||
import Suggestion from '@tiptap/suggestion';
|
||||
|
||||
import { MentionSuggestionMenu } from '@/mention/components/MentionSuggestionMenu';
|
||||
import { MENTION_SUGGESTION_PLUGIN_KEY } from '@/mention/constants/MentionSuggestionPluginKey';
|
||||
import type { MentionSearchResult } from '@/mention/types/MentionSearchResult';
|
||||
import { createSuggestionRenderLifecycle } from '@/ui/suggestion/components/createSuggestionRenderLifecycle';
|
||||
|
||||
type MentionSuggestionOptions = {
|
||||
searchMentionRecords: (query: string) => Promise<MentionSearchResult[]>;
|
||||
};
|
||||
|
||||
export const MentionSuggestion = Extension.create<MentionSuggestionOptions>({
|
||||
name: 'mention-suggestion',
|
||||
|
||||
addOptions: () => ({
|
||||
searchMentionRecords: async () => [],
|
||||
}),
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
searchMentionRecords: this.options.searchMentionRecords,
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion<MentionSearchResult>({
|
||||
pluginKey: MENTION_SUGGESTION_PLUGIN_KEY,
|
||||
editor: this.editor,
|
||||
char: '@',
|
||||
items: async ({ query }) => {
|
||||
try {
|
||||
return await this.storage.searchMentionRecords(query);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
command: ({ editor, range, props: selectedItem }) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent({
|
||||
type: 'mentionTag',
|
||||
attrs: {
|
||||
recordId: selectedItem.recordId,
|
||||
objectNameSingular: selectedItem.objectNameSingular,
|
||||
label: selectedItem.label,
|
||||
imageUrl: selectedItem.imageUrl,
|
||||
},
|
||||
})
|
||||
.insertContent(' ')
|
||||
.run();
|
||||
},
|
||||
render: () =>
|
||||
createSuggestionRenderLifecycle(
|
||||
{
|
||||
component: MentionSuggestionMenu,
|
||||
getMenuProps: ({ items, onSelect, editor, range }) => ({
|
||||
items,
|
||||
onSelect,
|
||||
editor,
|
||||
range,
|
||||
}),
|
||||
},
|
||||
this.editor,
|
||||
),
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import { MentionChip } from '@/mention/components/MentionChip';
|
||||
import { Node } from '@tiptap/core';
|
||||
import { mergeAttributes, ReactNodeViewRenderer } from '@tiptap/react';
|
||||
|
||||
export const MentionTag = Node.create({
|
||||
name: 'mentionTag',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
|
||||
addAttributes: () => ({
|
||||
recordId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-record-id'),
|
||||
renderHTML: (attributes) => ({
|
||||
'data-record-id': attributes.recordId,
|
||||
}),
|
||||
},
|
||||
objectNameSingular: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute('data-object-name-singular'),
|
||||
renderHTML: (attributes) => ({
|
||||
'data-object-name-singular': attributes.objectNameSingular,
|
||||
}),
|
||||
},
|
||||
label: {
|
||||
default: '',
|
||||
parseHTML: (element) => element.getAttribute('data-label'),
|
||||
renderHTML: (attributes) => ({
|
||||
'data-label': attributes.label,
|
||||
}),
|
||||
},
|
||||
imageUrl: {
|
||||
default: '',
|
||||
parseHTML: (element) => element.getAttribute('data-image-url'),
|
||||
renderHTML: (attributes) => ({
|
||||
'data-image-url': attributes.imageUrl,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
|
||||
renderHTML: ({ node, HTMLAttributes }) => {
|
||||
return [
|
||||
'span',
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
'data-type': 'mentionTag',
|
||||
class: 'mention-tag',
|
||||
}),
|
||||
`@${node.attrs.label}`,
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView: () => {
|
||||
return ReactNodeViewRenderer(MentionChip);
|
||||
},
|
||||
|
||||
renderText: ({ node }) => {
|
||||
const { objectNameSingular, recordId, label } = node.attrs;
|
||||
|
||||
return `[[record:${objectNameSingular}:${recordId}:${label}]]`;
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
import { Editor } from '@tiptap/core';
|
||||
import { Document } from '@tiptap/extension-document';
|
||||
import { Paragraph } from '@tiptap/extension-paragraph';
|
||||
import { Text } from '@tiptap/extension-text';
|
||||
|
||||
import { MentionSuggestion } from '@/mention/extensions/MentionSuggestion';
|
||||
import { MentionTag } from '@/mention/extensions/MentionTag';
|
||||
|
||||
// Mock ReactNodeViewRenderer and ReactRenderer (DOM-dependent)
|
||||
jest.mock('@tiptap/react', () => ({
|
||||
mergeAttributes: jest.requireActual('@tiptap/react').mergeAttributes,
|
||||
ReactNodeViewRenderer: () => () => ({}),
|
||||
ReactRenderer: jest.fn().mockImplementation(() => ({
|
||||
element: document.createElement('div'),
|
||||
ref: null,
|
||||
updateProps: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('MentionSuggestion', () => {
|
||||
let editor: Editor;
|
||||
let mockSearchFn: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSearchFn = jest.fn().mockResolvedValue([]);
|
||||
|
||||
editor = new Editor({
|
||||
extensions: [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
MentionTag,
|
||||
MentionSuggestion.configure({
|
||||
searchMentionRecords: mockSearchFn,
|
||||
}),
|
||||
],
|
||||
content: '<p></p>',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
|
||||
it('should register the extension', () => {
|
||||
const extension = editor.extensionManager.extensions.find(
|
||||
(ext) => ext.name === 'mention-suggestion',
|
||||
);
|
||||
|
||||
expect(extension).toBeDefined();
|
||||
});
|
||||
|
||||
it('should add ProseMirror plugins', () => {
|
||||
const plugins = editor.state.plugins;
|
||||
const hasSuggestionPlugin = plugins.some(
|
||||
(plugin) =>
|
||||
(plugin as unknown as { key: string }).key === 'mention-suggestion$',
|
||||
);
|
||||
|
||||
expect(hasSuggestionPlugin).toBe(true);
|
||||
});
|
||||
|
||||
it('should insert mention content via editor commands', () => {
|
||||
editor.commands.setContent('<p>Hello </p>');
|
||||
editor.commands.focus('end');
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent({
|
||||
type: 'mentionTag',
|
||||
attrs: {
|
||||
recordId: 'test-id',
|
||||
objectNameSingular: 'company',
|
||||
label: 'Acme',
|
||||
imageUrl: '',
|
||||
},
|
||||
})
|
||||
.insertContent(' ')
|
||||
.run();
|
||||
|
||||
const text = editor.getText();
|
||||
|
||||
expect(text).toContain('[[record:company:test-id:Acme]]');
|
||||
});
|
||||
|
||||
it('should accept @ character in editor content', () => {
|
||||
editor.commands.setContent('<p></p>');
|
||||
editor.commands.focus();
|
||||
editor.commands.insertContent('@');
|
||||
|
||||
expect(editor.getText()).toContain('@');
|
||||
});
|
||||
|
||||
it('should use default empty search function when not configured', () => {
|
||||
const unconfiguredEditor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, MentionTag, MentionSuggestion],
|
||||
content: '<p></p>',
|
||||
});
|
||||
|
||||
const extension = unconfiguredEditor.extensionManager.extensions.find(
|
||||
(ext) => ext.name === 'mention-suggestion',
|
||||
);
|
||||
|
||||
expect(extension?.options.searchMentionRecords).toBeDefined();
|
||||
|
||||
unconfiguredEditor.destroy();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
import { Editor } from '@tiptap/core';
|
||||
import { Document } from '@tiptap/extension-document';
|
||||
import { Paragraph } from '@tiptap/extension-paragraph';
|
||||
import { Text } from '@tiptap/extension-text';
|
||||
|
||||
import { MentionTag } from '@/mention/extensions/MentionTag';
|
||||
|
||||
// Mock ReactNodeViewRenderer since we're testing in a non-DOM environment
|
||||
jest.mock('@tiptap/react', () => ({
|
||||
mergeAttributes: jest.requireActual('@tiptap/react').mergeAttributes,
|
||||
ReactNodeViewRenderer: () => () => ({}),
|
||||
}));
|
||||
|
||||
describe('MentionTag', () => {
|
||||
let editor: Editor;
|
||||
|
||||
beforeEach(() => {
|
||||
editor = new Editor({
|
||||
extensions: [Document, Paragraph, Text, MentionTag],
|
||||
content: '<p></p>',
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
|
||||
describe('node spec', () => {
|
||||
it('should register as an inline atom node', () => {
|
||||
const mentionTagType = editor.schema.nodes.mentionTag;
|
||||
|
||||
expect(mentionTagType).toBeDefined();
|
||||
expect(mentionTagType.isInline).toBe(true);
|
||||
expect(mentionTagType.isAtom).toBe(true);
|
||||
});
|
||||
|
||||
it('should define all required attributes with defaults', () => {
|
||||
const attrs = editor.schema.nodes.mentionTag.spec.attrs;
|
||||
|
||||
expect(attrs).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderText', () => {
|
||||
it('should serialize a mention to [[record:...]] markdown format', () => {
|
||||
editor.commands.setContent({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{ type: 'text', text: 'Hello ' },
|
||||
{
|
||||
type: 'mentionTag',
|
||||
attrs: {
|
||||
recordId: 'abc-123',
|
||||
objectNameSingular: 'company',
|
||||
label: 'Acme Corp',
|
||||
imageUrl: 'https://example.com/logo.png',
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: ' world' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const text = editor.getText();
|
||||
|
||||
expect(text).toBe('Hello [[record:company:abc-123:Acme Corp]] world');
|
||||
});
|
||||
|
||||
it('should handle mentions with empty label', () => {
|
||||
editor.commands.setContent({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'mentionTag',
|
||||
attrs: {
|
||||
recordId: 'id-456',
|
||||
objectNameSingular: 'person',
|
||||
label: '',
|
||||
imageUrl: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const text = editor.getText();
|
||||
|
||||
expect(text).toBe('[[record:person:id-456:]]');
|
||||
});
|
||||
|
||||
it('should serialize multiple mentions in the same paragraph', () => {
|
||||
editor.commands.setContent({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'mentionTag',
|
||||
attrs: {
|
||||
recordId: 'r1',
|
||||
objectNameSingular: 'person',
|
||||
label: 'Alice',
|
||||
imageUrl: '',
|
||||
},
|
||||
},
|
||||
{ type: 'text', text: ' and ' },
|
||||
{
|
||||
type: 'mentionTag',
|
||||
attrs: {
|
||||
recordId: 'r2',
|
||||
objectNameSingular: 'company',
|
||||
label: 'Beta Inc',
|
||||
imageUrl: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const text = editor.getText();
|
||||
|
||||
expect(text).toBe(
|
||||
'[[record:person:r1:Alice]] and [[record:company:r2:Beta Inc]]',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertContent command', () => {
|
||||
it('should insert a mention tag via editor commands', () => {
|
||||
editor.commands.setContent('<p></p>');
|
||||
editor.commands.focus();
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent({
|
||||
type: 'mentionTag',
|
||||
attrs: {
|
||||
recordId: 'test-id',
|
||||
objectNameSingular: 'opportunity',
|
||||
label: 'Big Deal',
|
||||
imageUrl: 'https://example.com/img.png',
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
const text = editor.getText();
|
||||
|
||||
expect(text).toContain('[[record:opportunity:test-id:Big Deal]]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderHTML', () => {
|
||||
it('should produce HTML with data attributes', () => {
|
||||
editor.commands.setContent({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'mentionTag',
|
||||
attrs: {
|
||||
recordId: 'html-id',
|
||||
objectNameSingular: 'task',
|
||||
label: 'My Task',
|
||||
imageUrl: 'https://example.com/task.png',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const html = editor.getHTML();
|
||||
|
||||
expect(html).toContain('data-record-id="html-id"');
|
||||
expect(html).toContain('data-object-name-singular="task"');
|
||||
expect(html).toContain('data-label="My Task"');
|
||||
expect(html).toContain('data-image-url="https://example.com/task.png"');
|
||||
expect(html).toContain('data-type="mentionTag"');
|
||||
expect(html).toContain('class="mention-tag"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import { useMentionSearch } from '@/mention/hooks/useMentionSearch';
|
||||
import { type MentionItem } from '@/blocknote-editor/types/types';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
export const useMentionMenu = (editor: typeof BLOCK_SCHEMA.BlockNoteEditor) => {
|
||||
const { searchMentionRecords, searchableObjectMetadataItems } =
|
||||
useMentionSearch();
|
||||
|
||||
const getMentionItems = useCallback(
|
||||
async (query: string): Promise<MentionItem[]> => {
|
||||
const results = await searchMentionRecords(query);
|
||||
|
||||
return results.map((result) => {
|
||||
const objectMetadataItem = searchableObjectMetadataItems.find(
|
||||
(item) => item.nameSingular === result.objectNameSingular,
|
||||
);
|
||||
|
||||
return {
|
||||
title: result.label,
|
||||
recordId: result.recordId,
|
||||
objectNameSingular: result.objectNameSingular,
|
||||
objectMetadataId: objectMetadataItem?.id,
|
||||
label: result.label,
|
||||
imageUrl: result.imageUrl,
|
||||
objectLabelSingular: result.objectLabelSingular,
|
||||
onItemClick: () => {
|
||||
editor.insertInlineContent([
|
||||
{
|
||||
type: 'mention',
|
||||
props: {
|
||||
recordId: result.recordId,
|
||||
objectMetadataId: objectMetadataItem?.id ?? '',
|
||||
objectNameSingular: result.objectNameSingular,
|
||||
label: result.label,
|
||||
imageUrl: result.imageUrl,
|
||||
},
|
||||
},
|
||||
' ',
|
||||
]);
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
[editor, searchMentionRecords, searchableObjectMetadataItems],
|
||||
);
|
||||
|
||||
return getMentionItems;
|
||||
};
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
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 { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
type SearchQuery,
|
||||
type SearchQueryVariables,
|
||||
} from '~/generated/graphql';
|
||||
import type { MentionSearchResult } from '@/mention/types/MentionSearchResult';
|
||||
|
||||
const MENTION_SEARCH_LIMIT = 50;
|
||||
|
||||
export const useMentionSearch = () => {
|
||||
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 searchMentionRecords = useCallback(
|
||||
async (query: string): Promise<MentionSearchResult[]> => {
|
||||
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) || [];
|
||||
|
||||
return searchRecords.map((searchRecord) => ({
|
||||
recordId: searchRecord.recordId,
|
||||
objectNameSingular: searchRecord.objectNameSingular,
|
||||
objectLabelSingular: searchRecord.objectLabelSingular,
|
||||
label: searchRecord.label,
|
||||
imageUrl: searchRecord.imageUrl ?? '',
|
||||
}));
|
||||
},
|
||||
[apolloCoreClient, objectsToSearch],
|
||||
);
|
||||
|
||||
return { searchMentionRecords, searchableObjectMetadataItems };
|
||||
};
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export type MentionSearchResult = {
|
||||
recordId: string;
|
||||
objectNameSingular: string;
|
||||
objectLabelSingular: string;
|
||||
label: string;
|
||||
imageUrl: string;
|
||||
};
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import type { Editor, Range } from '@tiptap/core';
|
||||
|
||||
import type { MentionSearchResult } from '@/mention/types/MentionSearchResult';
|
||||
|
||||
export type MentionSuggestionMenuProps = {
|
||||
items: MentionSearchResult[];
|
||||
onSelect: (item: MentionSearchResult) => void;
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useRichTextFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useRichTextFieldDisplay';
|
||||
import { getFirstNonEmptyLineOfRichText } from '@/ui/input/editor/utils/getFirstNonEmptyLineOfRichText';
|
||||
import { getFirstNonEmptyLineOfRichText } from '@/blocknote-editor/utils/getFirstNonEmptyLineOfRichText';
|
||||
|
||||
export const RichTextFieldDisplay = () => {
|
||||
const { fieldValue } = useRichTextFieldDisplay();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useRichTextV2FieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useRichTextV2FieldDisplay';
|
||||
import { getFirstNonEmptyLineOfRichText } from '@/ui/input/editor/utils/getFirstNonEmptyLineOfRichText';
|
||||
import { getFirstNonEmptyLineOfRichText } from '@/blocknote-editor/utils/getFirstNonEmptyLineOfRichText';
|
||||
import type { PartialBlock } from '@blocknote/core';
|
||||
import { isNonEmptyString } from '@sniptt/guards';
|
||||
import { isDefined, parseJson } from 'twenty-shared/utils';
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { createPortal } from 'react-dom';
|
|||
import { IconColorSwatch, IconPlus, IconTrash } from 'twenty-ui/display';
|
||||
import { MenuItem } from 'twenty-ui/navigation';
|
||||
|
||||
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
|
||||
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import { DashboardBlockColorPicker } from '@/page-layout/widgets/standalone-rich-text/components/DashboardBlockColorPicker';
|
||||
import { DRAG_HANDLE_MENU_FLOATING_CONFIG } from '@/page-layout/widgets/standalone-rich-text/constants/DragHandleMenuFloatingConfig';
|
||||
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useState } from 'react';
|
|||
import { createPortal } from 'react-dom';
|
||||
import { IconGripVertical } from 'twenty-ui/display';
|
||||
|
||||
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
|
||||
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import { DashboardBlockDragHandleMenu } from '@/page-layout/widgets/standalone-rich-text/components/DashboardBlockDragHandleMenu';
|
||||
import { RootStackingContextZIndices } from '@/ui/layout/constants/RootStackingContextZIndices';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@ 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 { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import { getSlashMenu } from '@/blocknote-editor/utils/getSlashMenu';
|
||||
import { DashboardEditorSideMenu } from '@/page-layout/widgets/standalone-rich-text/components/DashboardEditorSideMenu';
|
||||
import { DashboardFormattingToolbar } from '@/page-layout/widgets/standalone-rich-text/components/DashboardFormattingToolbar';
|
||||
import {
|
||||
CustomSlashMenu,
|
||||
type SuggestionItem,
|
||||
} from '@/ui/input/editor/components/CustomSlashMenu';
|
||||
} from '@/blocknote-editor/components/CustomSlashMenu';
|
||||
|
||||
interface DashboardsBlockEditorProps {
|
||||
editor: typeof BLOCK_SCHEMA.BlockNoteEditor;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
|
||||
import { BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile';
|
||||
import { type Attachment } from '@/activities/files/types/Attachment';
|
||||
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
|
||||
|
|
@ -10,10 +10,10 @@ import { pageLayoutEditingWidgetIdComponentState } from '@/page-layout/states/pa
|
|||
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
|
||||
import { DashboardsBlockEditor } from '@/page-layout/widgets/standalone-rich-text/components/DashboardsBlockEditor';
|
||||
import { StandaloneRichTextWidgetAutoFocusEffect } from '@/page-layout/widgets/standalone-rich-text/components/StandaloneRichTextWidgetAutoFocusEffect';
|
||||
import { BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG } from '@/ui/input/editor/constants/BlockEditorGlobalHotkeysConfig';
|
||||
import { useAttachmentSync } from '@/ui/input/editor/hooks/useAttachmentSync';
|
||||
import { parseInitialBlocknote } from '@/ui/input/editor/utils/parseInitialBlocknote';
|
||||
import { prepareBodyWithSignedUrls } from '@/ui/input/editor/utils/prepareBodyWithSignedUrls';
|
||||
import { BLOCK_EDITOR_GLOBAL_HOTKEYS_CONFIG } from '@/blocknote-editor/constants/BlockEditorGlobalHotkeysConfig';
|
||||
import { useAttachmentSync } from '@/blocknote-editor/hooks/useAttachmentSync';
|
||||
import { parseInitialBlocknote } from '@/blocknote-editor/utils/parseInitialBlocknote';
|
||||
import { prepareBodyWithSignedUrls } from '@/blocknote-editor/utils/prepareBodyWithSignedUrls';
|
||||
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
|
||||
import { useRemoveFocusItemFromFocusStackById } from '@/ui/utilities/focus/hooks/useRemoveFocusItemFromFocusStackById';
|
||||
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { type BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema';
|
||||
import { type BLOCK_SCHEMA } from '@/blocknote-editor/blocks/Schema';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
type StandaloneRichTextWidgetAutoFocusEffectProps = {
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
export const MENTION_MENU_LIST_ID = 'mention-menu-list-id';
|
||||
|
|
@ -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,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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
|
|||
import { type ToolSet } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
|
||||
import { formatValidationErrors } from 'src/engine/core-modules/tool-provider/utils/format-validation-errors.util';
|
||||
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
||||
import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum';
|
||||
|
|
@ -56,6 +57,22 @@ const CreateViewInputSchema = z.object({
|
|||
.optional()
|
||||
.default(ViewVisibility.WORKSPACE)
|
||||
.describe('View visibility'),
|
||||
mainGroupByFieldName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Field name to group by (required for KANBAN views, must be a SELECT field, e.g., "stage", "status")',
|
||||
),
|
||||
kanbanAggregateOperation: z
|
||||
.enum(Object.values(AggregateOperations) as [string, ...string[]])
|
||||
.optional()
|
||||
.describe(
|
||||
'Aggregate operation for kanban columns (e.g., "SUM", "AVG", "COUNT")',
|
||||
),
|
||||
kanbanAggregateOperationFieldName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Field name for the kanban aggregate operation (e.g., "amount")'),
|
||||
});
|
||||
|
||||
const UpdateViewInputSchema = z.object({
|
||||
|
|
@ -101,6 +118,36 @@ export class ViewToolsFactory {
|
|||
return objectMetadata.id;
|
||||
}
|
||||
|
||||
private async resolveFieldMetadataId(
|
||||
workspaceId: string,
|
||||
objectMetadataId: string,
|
||||
fieldName: string,
|
||||
): Promise<string> {
|
||||
const { flatFieldMetadataMaps } =
|
||||
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
||||
{
|
||||
workspaceId,
|
||||
flatMapsKeys: ['flatFieldMetadataMaps'],
|
||||
},
|
||||
);
|
||||
|
||||
const fieldMetadata = Object.values(
|
||||
flatFieldMetadataMaps.byUniversalIdentifier,
|
||||
).find(
|
||||
(field) =>
|
||||
field?.name === fieldName &&
|
||||
field?.objectMetadataId === objectMetadataId,
|
||||
);
|
||||
|
||||
if (!fieldMetadata) {
|
||||
throw new Error(
|
||||
`Field "${fieldName}" not found on this object. Use get_field_metadata to list available fields.`,
|
||||
);
|
||||
}
|
||||
|
||||
return fieldMetadata.id;
|
||||
}
|
||||
|
||||
generateReadTools(
|
||||
workspaceId: string,
|
||||
userWorkspaceId?: string,
|
||||
|
|
@ -167,7 +214,7 @@ export class ViewToolsFactory {
|
|||
return {
|
||||
create_view: {
|
||||
description:
|
||||
'Create a new view for an object. Views define how records are displayed.',
|
||||
'Create a new view for an object. Views define how records are displayed. For KANBAN views, mainGroupByFieldName is required and must be a SELECT field (e.g., "stage", "status").',
|
||||
inputSchema: CreateViewInputSchema,
|
||||
execute: async (parameters: {
|
||||
name: string;
|
||||
|
|
@ -175,6 +222,9 @@ export class ViewToolsFactory {
|
|||
icon?: string;
|
||||
type?: ViewType;
|
||||
visibility?: ViewVisibility;
|
||||
mainGroupByFieldName?: string;
|
||||
kanbanAggregateOperation?: string;
|
||||
kanbanAggregateOperationFieldName?: string;
|
||||
}) => {
|
||||
try {
|
||||
const objectMetadataId = await this.resolveObjectMetadataId(
|
||||
|
|
@ -182,6 +232,26 @@ export class ViewToolsFactory {
|
|||
parameters.objectNameSingular,
|
||||
);
|
||||
|
||||
let mainGroupByFieldMetadataId: string | undefined;
|
||||
let kanbanAggregateOperationFieldMetadataId: string | undefined;
|
||||
|
||||
if (parameters.mainGroupByFieldName) {
|
||||
mainGroupByFieldMetadataId = await this.resolveFieldMetadataId(
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
parameters.mainGroupByFieldName,
|
||||
);
|
||||
}
|
||||
|
||||
if (parameters.kanbanAggregateOperationFieldName) {
|
||||
kanbanAggregateOperationFieldMetadataId =
|
||||
await this.resolveFieldMetadataId(
|
||||
workspaceId,
|
||||
objectMetadataId,
|
||||
parameters.kanbanAggregateOperationFieldName,
|
||||
);
|
||||
}
|
||||
|
||||
const view = await this.viewService.createOne({
|
||||
createViewInput: {
|
||||
name: parameters.name,
|
||||
|
|
@ -189,6 +259,10 @@ export class ViewToolsFactory {
|
|||
icon: parameters.icon ?? 'IconList',
|
||||
type: parameters.type ?? ViewType.TABLE,
|
||||
visibility: parameters.visibility ?? ViewVisibility.WORKSPACE,
|
||||
mainGroupByFieldMetadataId,
|
||||
kanbanAggregateOperation:
|
||||
parameters.kanbanAggregateOperation as AggregateOperations,
|
||||
kanbanAggregateOperationFieldMetadataId,
|
||||
},
|
||||
workspaceId,
|
||||
createdByUserWorkspaceId: userWorkspaceId,
|
||||
|
|
|
|||
Loading…
Reference in a new issue