diff --git a/.gitignore b/.gitignore index 0e1b8d1c25b..b3507fb6f73 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ dump.rdb mcp.json /.junie/ TRANSLATION_QA_REPORT.md +.playwright-mcp/ diff --git a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx index cc2a4530363..963d0a4c56b 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx @@ -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'; diff --git a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditorChangeOnActivityIdEffect.tsx b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditorChangeOnActivityIdEffect.tsx index fb0f28afe9a..64b8c83e829 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditorChangeOnActivityIdEffect.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditorChangeOnActivityIdEffect.tsx @@ -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'; diff --git a/packages/twenty-front/src/modules/activities/hooks/useReplaceActivityBlockEditorContent.ts b/packages/twenty-front/src/modules/activities/hooks/useReplaceActivityBlockEditorContent.ts index 5ba5a53653a..7f7acc5b3d0 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useReplaceActivityBlockEditorContent.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useReplaceActivityBlockEditorContent.ts @@ -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'; diff --git a/packages/twenty-front/src/modules/advanced-text-editor/extensions/slash-command/SlashCommand.ts b/packages/twenty-front/src/modules/advanced-text-editor/extensions/slash-command/SlashCommand.ts index 36c91f55b8e..2e6d9fa87aa 100644 --- a/packages/twenty-front/src/modules/advanced-text-editor/extensions/slash-command/SlashCommand.ts +++ b/packages/twenty-front/src/modules/advanced-text-editor/extensions/slash-command/SlashCommand.ts @@ -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({ 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, + ), }), ]; }, diff --git a/packages/twenty-front/src/modules/advanced-text-editor/extensions/slash-command/SlashCommandMenu.tsx b/packages/twenty-front/src/modules/advanced-text-editor/extensions/slash-command/SlashCommandMenu.tsx index 5447b7b1cf3..433562b9424 100644 --- a/packages/twenty-front/src/modules/advanced-text-editor/extensions/slash-command/SlashCommandMenu.tsx +++ b/packages/twenty-front/src/modules/advanced-text-editor/extensions/slash-command/SlashCommandMenu.tsx @@ -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( - (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(null); - const commandListContainerRef = useRef(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) => ( + { + onSelect(item); + }} + /> + ), + [onSelect], + ); return ( - - - - - - {items.map((item, index) => { - const isSelected = index === selectedIndex; - - return ( -
{ - e.preventDefault(); - }} - > - { - onSelect(item); - }} - /> -
- ); - })} -
-
-
-
-
+ ); }, ); diff --git a/packages/twenty-front/src/modules/advanced-text-editor/extensions/slash-command/SlashCommandRenderer.ts b/packages/twenty-front/src/modules/advanced-text-editor/extensions/slash-command/SlashCommandRenderer.ts deleted file mode 100644 index 1cf89c2abeb..00000000000 --- a/packages/twenty-front/src/modules/advanced-text-editor/extensions/slash-command/SlashCommandRenderer.ts +++ /dev/null @@ -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): 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; - } -} diff --git a/packages/twenty-front/src/modules/advanced-text-editor/extensions/slash-command/__tests__/SlashCommandRenderer.test.ts b/packages/twenty-front/src/modules/advanced-text-editor/extensions/slash-command/__tests__/SlashCommandRenderer.test.ts deleted file mode 100644 index afd915d67a0..00000000000 --- a/packages/twenty-front/src/modules/advanced-text-editor/extensions/slash-command/__tests__/SlashCommandRenderer.test.ts +++ /dev/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: '

', - }); - - 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); - }); - }); -}); diff --git a/packages/twenty-front/src/modules/ai/components/AIChatAssistantMessageRenderer.tsx b/packages/twenty-front/src/modules/ai/components/AIChatAssistantMessageRenderer.tsx index 538ca49f943..dc623de8a28 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatAssistantMessageRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatAssistantMessageRenderer.tsx @@ -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 ; + return ; } return null; } @@ -117,7 +123,11 @@ export const AIChatAssistantMessageRenderer = ({
{filteredParts.map((part, index) => ( - + ))} {isLastMessageStreaming && !hasError && } diff --git a/packages/twenty-front/src/modules/ai/components/AIChatEmptyState.tsx b/packages/twenty-front/src/modules/ai/components/AIChatEmptyState.tsx index fc83b72db12..6b6f83a7be0 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatEmptyState.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatEmptyState.tsx @@ -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 ( - + ); }; diff --git a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx index d0d42a6e14c..b83ed5d42e3 100644 --- a/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx +++ b/packages/twenty-front/src/modules/ai/components/AIChatTab.tsx @@ -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 ( { )} {messages.length === 0 && !error && !isLoading && ( - + )} {messages.length === 0 && error && !isLoading && ( @@ -168,16 +181,9 @@ export const AIChatTab = () => { - - setAgentChatInput(value)} - minRows={3} - maxRows={20} - /> - + + + { ariaLabel={t`View Previous AI Chats`} /> - + diff --git a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx index dac9beb99da..545d4253fc8 100644 --- a/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/LazyMarkdownRenderer.tsx @@ -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 ( - }> - - {text} - - + + }> + + {text} + + + ); }; diff --git a/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx b/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx index 0e7f0c10af9..2aee2339d7d 100644 --- a/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx +++ b/packages/twenty-front/src/modules/ai/components/ToolStepRenderer.tsx @@ -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 ( { } if (!output && !hasError) { + const displayText = isStreaming + ? getToolDisplayMessage(input, rawToolName, false) + : getToolDisplayMessage(input, rawToolName, true); + return ( - - - {getToolDisplayMessage(input, rawToolName, false)} - - + {isStreaming ? ( + + {displayText} + + ) : ( + {displayText} + )} diff --git a/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx b/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx index 60821915ffd..e90ce601567 100644 --- a/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx +++ b/packages/twenty-front/src/modules/ai/components/internal/AIChatContextUsageButton.tsx @@ -161,6 +161,7 @@ export const AIChatContextUsageButton = () => { {isHovered && ( + {t`Context window`} {formattedPercentage}% diff --git a/packages/twenty-front/src/modules/ai/components/internal/SendMessageButton.tsx b/packages/twenty-front/src/modules/ai/components/internal/SendMessageButton.tsx index 7ab88934a3e..930948fafb8 100644 --- a/packages/twenty-front/src/modules/ai/components/internal/SendMessageButton.tsx +++ b/packages/twenty-front/src/modules/ai/components/internal/SendMessageButton.tsx @@ -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 = () => { handleSendMessage()} + onClick={onSend} disabled={!agentChatInput || isLoading} /> ); diff --git a/packages/twenty-front/src/modules/ai/components/suggested-prompts/AIChatSuggestedPrompts.tsx b/packages/twenty-front/src/modules/ai/components/suggested-prompts/AIChatSuggestedPrompts.tsx index 969a41221c9..d9aad02047a 100644 --- a/packages/twenty-front/src/modules/ai/components/suggested-prompts/AIChatSuggestedPrompts.tsx +++ b/packages/twenty-front/src/modules/ai/components/suggested-prompts/AIChatSuggestedPrompts.tsx @@ -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 = (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 ( diff --git a/packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts b/packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts new file mode 100644 index 00000000000..ee31512630d --- /dev/null +++ b/packages/twenty-front/src/modules/ai/hooks/useAIChatEditor.ts @@ -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 }; +}; diff --git a/packages/twenty-front/src/modules/ai/utils/__tests__/getToolDisplayMessage.test.ts b/packages/twenty-front/src/modules/ai/utils/__tests__/getToolDisplayMessage.test.ts new file mode 100644 index 00000000000..857d43a3551 --- /dev/null +++ b/packages/twenty-front/src/modules/ai/utils/__tests__/getToolDisplayMessage.test.ts @@ -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'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts b/packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts index ce16f0ea3d6..df8481850bd 100644 --- a/packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts +++ b/packages/twenty-front/src/modules/ai/utils/getToolDisplayMessage.ts @@ -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}`, ); } diff --git a/packages/twenty-front/src/modules/activities/blocks/components/FileBlock.tsx b/packages/twenty-front/src/modules/blocknote-editor/blocks/FileBlock.tsx similarity index 100% rename from packages/twenty-front/src/modules/activities/blocks/components/FileBlock.tsx rename to packages/twenty-front/src/modules/blocknote-editor/blocks/FileBlock.tsx diff --git a/packages/twenty-front/src/modules/activities/blocks/components/MentionInlineContent.tsx b/packages/twenty-front/src/modules/blocknote-editor/blocks/MentionInlineContent.tsx similarity index 65% rename from packages/twenty-front/src/modules/activities/blocks/components/MentionInlineContent.tsx rename to packages/twenty-front/src/modules/blocknote-editor/blocks/MentionInlineContent.tsx index dff8a6be18f..d4474dbc260 100644 --- a/packages/twenty-front/src/modules/activities/blocks/components/MentionInlineContent.tsx +++ b/packages/twenty-front/src/modules/blocknote-editor/blocks/MentionInlineContent.tsx @@ -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 ( + + ); + } + + // Legacy notes only have objectMetadataId + recordId: fetch data return ( - diff --git a/packages/twenty-front/src/modules/activities/blocks/constants/Schema.ts b/packages/twenty-front/src/modules/blocknote-editor/blocks/Schema.ts similarity index 67% rename from packages/twenty-front/src/modules/activities/blocks/constants/Schema.ts rename to packages/twenty-front/src/modules/blocknote-editor/blocks/Schema.ts index c2eb65b990d..3868774960c 100644 --- a/packages/twenty-front/src/modules/activities/blocks/constants/Schema.ts +++ b/packages/twenty-front/src/modules/blocknote-editor/blocks/Schema.ts @@ -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: { diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/BlockEditor.tsx b/packages/twenty-front/src/modules/blocknote-editor/components/BlockEditor.tsx similarity index 91% rename from packages/twenty-front/src/modules/ui/input/editor/components/BlockEditor.tsx rename to packages/twenty-front/src/modules/blocknote-editor/components/BlockEditor.tsx index ce25728a7f9..a836b162c05 100644 --- a/packages/twenty-front/src/modules/ui/input/editor/components/BlockEditor.tsx +++ b/packages/twenty-front/src/modules/blocknote-editor/components/BlockEditor.tsx @@ -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; diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/BlockEditorDropdownFocusEffect.tsx b/packages/twenty-front/src/modules/blocknote-editor/components/BlockEditorDropdownFocusEffect.tsx similarity index 92% rename from packages/twenty-front/src/modules/ui/input/editor/components/BlockEditorDropdownFocusEffect.tsx rename to packages/twenty-front/src/modules/blocknote-editor/components/BlockEditorDropdownFocusEffect.tsx index dc29cc012a5..78b38fb3974 100644 --- a/packages/twenty-front/src/modules/ui/input/editor/components/BlockEditorDropdownFocusEffect.tsx +++ b/packages/twenty-front/src/modules/blocknote-editor/components/BlockEditorDropdownFocusEffect.tsx @@ -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'; diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/CustomAddBlockItem.tsx b/packages/twenty-front/src/modules/blocknote-editor/components/CustomAddBlockItem.tsx similarity index 93% rename from packages/twenty-front/src/modules/ui/input/editor/components/CustomAddBlockItem.tsx rename to packages/twenty-front/src/modules/blocknote-editor/components/CustomAddBlockItem.tsx index f974c76a4c4..9b35ee64e9f 100644 --- a/packages/twenty-front/src/modules/ui/input/editor/components/CustomAddBlockItem.tsx +++ b/packages/twenty-front/src/modules/blocknote-editor/components/CustomAddBlockItem.tsx @@ -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'; diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenu.tsx b/packages/twenty-front/src/modules/blocknote-editor/components/CustomMentionMenu.tsx similarity index 63% rename from packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenu.tsx rename to packages/twenty-front/src/modules/blocknote-editor/components/CustomMentionMenu.tsx index 9881797e75b..82ffea690ac 100644 --- a/packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenu.tsx +++ b/packages/twenty-front/src/modules/blocknote-editor/components/CustomMentionMenu.tsx @@ -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 ( - <> {createPortal( - item.recordId!, - )} - > - {filteredItems.map((item) => ( - onItemClick?.(item)} - objectNameSingular={item.objectNameSingular!} - /> - ))} - + {filteredItems.map((item, index) => ( + onItemClick?.(item)} + /> + ))} diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/CustomSideMenu.tsx b/packages/twenty-front/src/modules/blocknote-editor/components/CustomSideMenu.tsx similarity index 89% rename from packages/twenty-front/src/modules/ui/input/editor/components/CustomSideMenu.tsx rename to packages/twenty-front/src/modules/blocknote-editor/components/CustomSideMenu.tsx index fcd68f18735..385af83ee40 100644 --- a/packages/twenty-front/src/modules/ui/input/editor/components/CustomSideMenu.tsx +++ b/packages/twenty-front/src/modules/blocknote-editor/components/CustomSideMenu.tsx @@ -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, diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/CustomSideMenuOptions.tsx b/packages/twenty-front/src/modules/blocknote-editor/components/CustomSideMenuOptions.tsx similarity index 100% rename from packages/twenty-front/src/modules/ui/input/editor/components/CustomSideMenuOptions.tsx rename to packages/twenty-front/src/modules/blocknote-editor/components/CustomSideMenuOptions.tsx diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenu.tsx b/packages/twenty-front/src/modules/blocknote-editor/components/CustomSlashMenu.tsx similarity index 91% rename from packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenu.tsx rename to packages/twenty-front/src/modules/blocknote-editor/components/CustomSlashMenu.tsx index 299a86ce2fa..a279b9b123d 100644 --- a/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenu.tsx +++ b/packages/twenty-front/src/modules/blocknote-editor/components/CustomSlashMenu.tsx @@ -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'; diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenuListItem.tsx b/packages/twenty-front/src/modules/blocknote-editor/components/CustomSlashMenuListItem.tsx similarity index 94% rename from packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenuListItem.tsx rename to packages/twenty-front/src/modules/blocknote-editor/components/CustomSlashMenuListItem.tsx index e1950a7edaf..3e88f2f5428 100644 --- a/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenuListItem.tsx +++ b/packages/twenty-front/src/modules/blocknote-editor/components/CustomSlashMenuListItem.tsx @@ -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'; diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenuSelectedIndexSyncEffect.tsx b/packages/twenty-front/src/modules/blocknote-editor/components/CustomSlashMenuSelectedIndexSyncEffect.tsx similarity index 91% rename from packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenuSelectedIndexSyncEffect.tsx rename to packages/twenty-front/src/modules/blocknote-editor/components/CustomSlashMenuSelectedIndexSyncEffect.tsx index 805d4ecf0ca..a845361b212 100644 --- a/packages/twenty-front/src/modules/ui/input/editor/components/CustomSlashMenuSelectedIndexSyncEffect.tsx +++ b/packages/twenty-front/src/modules/blocknote-editor/components/CustomSlashMenuSelectedIndexSyncEffect.tsx @@ -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'; diff --git a/packages/twenty-front/src/modules/ui/input/editor/constants/BlockEditorGlobalHotkeysConfig.ts b/packages/twenty-front/src/modules/blocknote-editor/constants/BlockEditorGlobalHotkeysConfig.ts similarity index 100% rename from packages/twenty-front/src/modules/ui/input/editor/constants/BlockEditorGlobalHotkeysConfig.ts rename to packages/twenty-front/src/modules/blocknote-editor/constants/BlockEditorGlobalHotkeysConfig.ts diff --git a/packages/twenty-front/src/modules/ui/input/editor/contexts/BlockEditorCompoponeInstanceContext.ts b/packages/twenty-front/src/modules/blocknote-editor/contexts/BlockEditorCompoponeInstanceContext.ts similarity index 100% rename from packages/twenty-front/src/modules/ui/input/editor/contexts/BlockEditorCompoponeInstanceContext.ts rename to packages/twenty-front/src/modules/blocknote-editor/contexts/BlockEditorCompoponeInstanceContext.ts diff --git a/packages/twenty-front/src/modules/ui/input/editor/hooks/useAttachmentSync.ts b/packages/twenty-front/src/modules/blocknote-editor/hooks/useAttachmentSync.ts similarity index 100% rename from packages/twenty-front/src/modules/ui/input/editor/hooks/useAttachmentSync.ts rename to packages/twenty-front/src/modules/blocknote-editor/hooks/useAttachmentSync.ts diff --git a/packages/twenty-front/src/modules/ui/input/editor/states/isSlashMenuOpenComponentState.ts b/packages/twenty-front/src/modules/blocknote-editor/states/isSlashMenuOpenComponentState.ts similarity index 72% rename from packages/twenty-front/src/modules/ui/input/editor/states/isSlashMenuOpenComponentState.ts rename to packages/twenty-front/src/modules/blocknote-editor/states/isSlashMenuOpenComponentState.ts index 3ebd20e5b1d..edaf11375e2 100644 --- a/packages/twenty-front/src/modules/ui/input/editor/states/isSlashMenuOpenComponentState.ts +++ b/packages/twenty-front/src/modules/blocknote-editor/states/isSlashMenuOpenComponentState.ts @@ -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({ diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/types.ts b/packages/twenty-front/src/modules/blocknote-editor/types/types.ts similarity index 88% rename from packages/twenty-front/src/modules/ui/input/editor/components/types.ts rename to packages/twenty-front/src/modules/blocknote-editor/types/types.ts index 7d10aa8eef9..c212ebb7a3f 100644 --- a/packages/twenty-front/src/modules/ui/input/editor/components/types.ts +++ b/packages/twenty-front/src/modules/blocknote-editor/types/types.ts @@ -15,6 +15,9 @@ export type MentionItem = DefaultReactSuggestionItem & { recordId?: string; objectNameSingular?: string; objectMetadataId?: string; + label?: string; + imageUrl?: string; + objectLabelSingular?: string; }; export type CustomMentionMenuProps = SuggestionMenuProps; diff --git a/packages/twenty-front/src/modules/ui/input/editor/utils/__tests__/getFirstNonEmptyLineOfRichText.test.ts b/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/getFirstNonEmptyLineOfRichText.test.ts similarity index 96% rename from packages/twenty-front/src/modules/ui/input/editor/utils/__tests__/getFirstNonEmptyLineOfRichText.test.ts rename to packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/getFirstNonEmptyLineOfRichText.test.ts index 4217fb71d90..8057906d3bd 100644 --- a/packages/twenty-front/src/modules/ui/input/editor/utils/__tests__/getFirstNonEmptyLineOfRichText.test.ts +++ b/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/getFirstNonEmptyLineOfRichText.test.ts @@ -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', () => { diff --git a/packages/twenty-front/src/modules/ui/input/editor/utils/__tests__/parseInitialBlocknote.test.ts b/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/parseInitialBlocknote.test.ts similarity index 94% rename from packages/twenty-front/src/modules/ui/input/editor/utils/__tests__/parseInitialBlocknote.test.ts rename to packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/parseInitialBlocknote.test.ts index 872f5d6c865..b8894a89565 100644 --- a/packages/twenty-front/src/modules/ui/input/editor/utils/__tests__/parseInitialBlocknote.test.ts +++ b/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/parseInitialBlocknote.test.ts @@ -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', () => { diff --git a/packages/twenty-front/src/modules/ui/input/editor/utils/__tests__/prepareBodyWithSignedUrls.test.ts b/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/prepareBodyWithSignedUrls.test.ts similarity index 94% rename from packages/twenty-front/src/modules/ui/input/editor/utils/__tests__/prepareBodyWithSignedUrls.test.ts rename to packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/prepareBodyWithSignedUrls.test.ts index aa37849d47e..cdcacec31f9 100644 --- a/packages/twenty-front/src/modules/ui/input/editor/utils/__tests__/prepareBodyWithSignedUrls.test.ts +++ b/packages/twenty-front/src/modules/blocknote-editor/utils/__tests__/prepareBodyWithSignedUrls.test.ts @@ -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', () => { diff --git a/packages/twenty-front/src/modules/ui/input/editor/utils/getFirstNonEmptyLineOfRichText.ts b/packages/twenty-front/src/modules/blocknote-editor/utils/getFirstNonEmptyLineOfRichText.ts similarity index 100% rename from packages/twenty-front/src/modules/ui/input/editor/utils/getFirstNonEmptyLineOfRichText.ts rename to packages/twenty-front/src/modules/blocknote-editor/utils/getFirstNonEmptyLineOfRichText.ts diff --git a/packages/twenty-front/src/modules/activities/blocks/utils/getSlashMenu.ts b/packages/twenty-front/src/modules/blocknote-editor/utils/getSlashMenu.ts similarity index 89% rename from packages/twenty-front/src/modules/activities/blocks/utils/getSlashMenu.ts rename to packages/twenty-front/src/modules/blocknote-editor/utils/getSlashMenu.ts index 6a5f7d17c96..aa0dd04312f 100644 --- a/packages/twenty-front/src/modules/activities/blocks/utils/getSlashMenu.ts +++ b/packages/twenty-front/src/modules/blocknote-editor/utils/getSlashMenu.ts @@ -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, diff --git a/packages/twenty-front/src/modules/ui/input/editor/utils/parseInitialBlocknote.ts b/packages/twenty-front/src/modules/blocknote-editor/utils/parseInitialBlocknote.ts similarity index 100% rename from packages/twenty-front/src/modules/ui/input/editor/utils/parseInitialBlocknote.ts rename to packages/twenty-front/src/modules/blocknote-editor/utils/parseInitialBlocknote.ts diff --git a/packages/twenty-front/src/modules/ui/input/editor/utils/prepareBodyWithSignedUrls.ts b/packages/twenty-front/src/modules/blocknote-editor/utils/prepareBodyWithSignedUrls.ts similarity index 100% rename from packages/twenty-front/src/modules/ui/input/editor/utils/prepareBodyWithSignedUrls.ts rename to packages/twenty-front/src/modules/blocknote-editor/utils/prepareBodyWithSignedUrls.ts diff --git a/packages/twenty-front/src/modules/mention/components/MentionChip.tsx b/packages/twenty-front/src/modules/mention/components/MentionChip.tsx new file mode 100644 index 00000000000..6e0aafd937c --- /dev/null +++ b/packages/twenty-front/src/modules/mention/components/MentionChip.tsx @@ -0,0 +1,24 @@ +import { type NodeViewProps } from '@tiptap/core'; +import { NodeViewWrapper } from '@tiptap/react'; + +import { MentionRecordChip } from '@/mention/components/MentionRecordChip'; + +type MentionChipProps = Pick; + +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 ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/mention/components/MentionMenuListItem.tsx b/packages/twenty-front/src/modules/mention/components/MentionMenuListItem.tsx new file mode 100644 index 00000000000..74d6aa244b3 --- /dev/null +++ b/packages/twenty-front/src/modules/mention/components/MentionMenuListItem.tsx @@ -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 ( + ( + + )} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/mention/components/MentionRecordChip.tsx b/packages/twenty-front/src/modules/mention/components/MentionRecordChip.tsx new file mode 100644 index 00000000000..b41702d1111 --- /dev/null +++ b/packages/twenty-front/src/modules/mention/components/MentionRecordChip.tsx @@ -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 ( + + ); + } + + if (!isNonEmptyString(recordId)) { + return ( + + ); + } + + const linkToShowPage = getLinkToShowPage(objectNameSingular, { + id: recordId, + }); + + return ( + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/mention/components/MentionSuggestionMenu.tsx b/packages/twenty-front/src/modules/mention/components/MentionSuggestionMenu.tsx new file mode 100644 index 00000000000..8054bd228b4 --- /dev/null +++ b/packages/twenty-front/src/modules/mention/components/MentionSuggestionMenu.tsx @@ -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, +) => ( + ( + + )} + 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 ( + renderItem(item, isSelected, onSelect)} + /> + ); +}); diff --git a/packages/twenty-front/src/modules/mention/constants/MentionSuggestionPluginKey.ts b/packages/twenty-front/src/modules/mention/constants/MentionSuggestionPluginKey.ts new file mode 100644 index 00000000000..fa577ebf5f7 --- /dev/null +++ b/packages/twenty-front/src/modules/mention/constants/MentionSuggestionPluginKey.ts @@ -0,0 +1,5 @@ +import { PluginKey } from '@tiptap/pm/state'; + +export const MENTION_SUGGESTION_PLUGIN_KEY = new PluginKey( + 'mention-suggestion', +); diff --git a/packages/twenty-front/src/modules/mention/extensions/MentionSuggestion.ts b/packages/twenty-front/src/modules/mention/extensions/MentionSuggestion.ts new file mode 100644 index 00000000000..376b919e158 --- /dev/null +++ b/packages/twenty-front/src/modules/mention/extensions/MentionSuggestion.ts @@ -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; +}; + +export const MentionSuggestion = Extension.create({ + name: 'mention-suggestion', + + addOptions: () => ({ + searchMentionRecords: async () => [], + }), + + addStorage() { + return { + searchMentionRecords: this.options.searchMentionRecords, + }; + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + 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, + ), + }), + ]; + }, +}); diff --git a/packages/twenty-front/src/modules/mention/extensions/MentionTag.ts b/packages/twenty-front/src/modules/mention/extensions/MentionTag.ts new file mode 100644 index 00000000000..972dba3f7dc --- /dev/null +++ b/packages/twenty-front/src/modules/mention/extensions/MentionTag.ts @@ -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}]]`; + }, +}); diff --git a/packages/twenty-front/src/modules/mention/extensions/__tests__/MentionSuggestion.test.ts b/packages/twenty-front/src/modules/mention/extensions/__tests__/MentionSuggestion.test.ts new file mode 100644 index 00000000000..25afe3fc8b9 --- /dev/null +++ b/packages/twenty-front/src/modules/mention/extensions/__tests__/MentionSuggestion.test.ts @@ -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: '

', + }); + }); + + 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('

Hello

'); + 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('

'); + 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: '

', + }); + + const extension = unconfiguredEditor.extensionManager.extensions.find( + (ext) => ext.name === 'mention-suggestion', + ); + + expect(extension?.options.searchMentionRecords).toBeDefined(); + + unconfiguredEditor.destroy(); + }); +}); diff --git a/packages/twenty-front/src/modules/mention/extensions/__tests__/MentionTag.test.ts b/packages/twenty-front/src/modules/mention/extensions/__tests__/MentionTag.test.ts new file mode 100644 index 00000000000..6ab95dffbb3 --- /dev/null +++ b/packages/twenty-front/src/modules/mention/extensions/__tests__/MentionTag.test.ts @@ -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: '

', + }); + }); + + 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('

'); + 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"'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/mention/hooks/useMentionMenu.ts b/packages/twenty-front/src/modules/mention/hooks/useMentionMenu.ts new file mode 100644 index 00000000000..e53c9076b44 --- /dev/null +++ b/packages/twenty-front/src/modules/mention/hooks/useMentionMenu.ts @@ -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 => { + 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; +}; diff --git a/packages/twenty-front/src/modules/mention/hooks/useMentionSearch.ts b/packages/twenty-front/src/modules/mention/hooks/useMentionSearch.ts new file mode 100644 index 00000000000..e91b7222a34 --- /dev/null +++ b/packages/twenty-front/src/modules/mention/hooks/useMentionSearch.ts @@ -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 => { + 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 }; +}; diff --git a/packages/twenty-front/src/modules/mention/types/MentionSearchResult.ts b/packages/twenty-front/src/modules/mention/types/MentionSearchResult.ts new file mode 100644 index 00000000000..83a0da69627 --- /dev/null +++ b/packages/twenty-front/src/modules/mention/types/MentionSearchResult.ts @@ -0,0 +1,7 @@ +export type MentionSearchResult = { + recordId: string; + objectNameSingular: string; + objectLabelSingular: string; + label: string; + imageUrl: string; +}; diff --git a/packages/twenty-front/src/modules/mention/types/MentionSuggestionMenuProps.ts b/packages/twenty-front/src/modules/mention/types/MentionSuggestionMenuProps.ts new file mode 100644 index 00000000000..a55da773f79 --- /dev/null +++ b/packages/twenty-front/src/modules/mention/types/MentionSuggestionMenuProps.ts @@ -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; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RichTextFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RichTextFieldDisplay.tsx index ddd2170bc95..479738da34c 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RichTextFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RichTextFieldDisplay.tsx @@ -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(); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RichTextV2FieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RichTextV2FieldDisplay.tsx index d2f60c06861..9cc21f07c7d 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RichTextV2FieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/meta-types/display/components/RichTextV2FieldDisplay.tsx @@ -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'; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardBlockDragHandleMenu.tsx b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardBlockDragHandleMenu.tsx index 9e073de9eed..2cbfb8cd127 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardBlockDragHandleMenu.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardBlockDragHandleMenu.tsx @@ -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'; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardEditorSideMenu.tsx b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardEditorSideMenu.tsx index 3334ddfd07b..e61505d3e0e 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardEditorSideMenu.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardEditorSideMenu.tsx @@ -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'; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardsBlockEditor.tsx b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardsBlockEditor.tsx index f9e57eb1c04..b59c0611905 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardsBlockEditor.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/DashboardsBlockEditor.tsx @@ -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; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/StandaloneRichTextEditorContent.tsx b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/StandaloneRichTextEditorContent.tsx index 2056fc792c4..26d3b9d0227 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/StandaloneRichTextEditorContent.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/StandaloneRichTextEditorContent.tsx @@ -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'; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/StandaloneRichTextWidgetAutoFocusEffect.tsx b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/StandaloneRichTextWidgetAutoFocusEffect.tsx index 122e77cf2a2..55e84daf460 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/StandaloneRichTextWidgetAutoFocusEffect.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/standalone-rich-text/components/StandaloneRichTextWidgetAutoFocusEffect.tsx @@ -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 = { diff --git a/packages/twenty-front/src/modules/ui/input/constants/MentionMenuListId.ts b/packages/twenty-front/src/modules/ui/input/constants/MentionMenuListId.ts deleted file mode 100644 index 39b97203292..00000000000 --- a/packages/twenty-front/src/modules/ui/input/constants/MentionMenuListId.ts +++ /dev/null @@ -1 +0,0 @@ -export const MENTION_MENU_LIST_ID = 'mention-menu-list-id'; diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenuListItem.tsx b/packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenuListItem.tsx deleted file mode 100644 index 290689c0c9f..00000000000 --- a/packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenuListItem.tsx +++ /dev/null @@ -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 ( - - ( - - )} - /> - - ); -}; diff --git a/packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenuSelectedIndexSyncEffect.tsx b/packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenuSelectedIndexSyncEffect.tsx deleted file mode 100644 index 077bfd870df..00000000000 --- a/packages/twenty-front/src/modules/ui/input/editor/components/CustomMentionMenuSelectedIndexSyncEffect.tsx +++ /dev/null @@ -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 <>; -}; diff --git a/packages/twenty-front/src/modules/ui/input/editor/hooks/useMentionMenu.ts b/packages/twenty-front/src/modules/ui/input/editor/hooks/useMentionMenu.ts deleted file mode 100644 index fe7d01c4204..00000000000 --- a/packages/twenty-front/src/modules/ui/input/editor/hooks/useMentionMenu.ts +++ /dev/null @@ -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 => { - 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; -}; diff --git a/packages/twenty-front/src/modules/ui/suggestion/components/SuggestionMenu.tsx b/packages/twenty-front/src/modules/ui/suggestion/components/SuggestionMenu.tsx new file mode 100644 index 00000000000..94c5a67bf63 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/suggestion/components/SuggestionMenu.tsx @@ -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 = SuggestionMenuProps; + +// forwardRef does not natively support generics, so we use a helper + cast +const SuggestionMenuInner = ( + props: SuggestionMenuInnerProps, + parentRef: React.ForwardedRef, +) => { + 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(null); + const listContainerRef = useRef(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 ( + + + + + {items.map((item, index) => { + const isSelected = index === clampedSelectedIndex; + + return ( +
{ + event.preventDefault(); + }} + > + {renderItem(item, isSelected)} +
+ ); + })} +
+
+
+
+ ); +}; + +export const SuggestionMenu = forwardRef(SuggestionMenuInner) as ( + props: SuggestionMenuProps & { ref?: React.Ref }, +) => ReturnType; diff --git a/packages/twenty-front/src/modules/ui/suggestion/components/__tests__/createSuggestionRenderLifecycle.test.ts b/packages/twenty-front/src/modules/ui/suggestion/components/__tests__/createSuggestionRenderLifecycle.test.ts new file mode 100644 index 00000000000..1f1a3e78b1c --- /dev/null +++ b/packages/twenty-front/src/modules/ui/suggestion/components/__tests__/createSuggestionRenderLifecycle.test.ts @@ -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( + { + component: (() => null) as unknown as React.ComponentType, + 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(); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/ui/suggestion/components/createSuggestionRenderLifecycle.ts b/packages/twenty-front/src/modules/ui/suggestion/components/createSuggestionRenderLifecycle.ts new file mode 100644 index 00000000000..1ebafb738bb --- /dev/null +++ b/packages/twenty-front/src/modules/ui/suggestion/components/createSuggestionRenderLifecycle.ts @@ -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 = { + items: TItem[]; + command: (item: TItem) => void; + clientRect?: (() => DOMRect | null) | null; + range: Range; + query: string; +}; + +// Matches Tiptap's ReactRenderer generic constraint +type AnyRecord = Record; + +type SuggestionRenderLifecycleConfig = { + component: React.ComponentType; + getMenuProps: (args: { + items: TItem[]; + onSelect: (item: TItem) => void; + editor: Editor; + range: Range; + query: string; + }) => TMenuProps; +}; + +export const createSuggestionRenderLifecycle = < + TItem, + TMenuProps extends AnyRecord, +>( + config: SuggestionRenderLifecycleConfig, + editor: Editor, +) => { + let renderer: ReactRenderer | null = null; + + const closeMenu = () => { + if (renderer !== null) { + renderer.destroy(); + renderer = null; + } + }; + + const buildMenuProps = (props: SuggestionCallbackProps) => + config.getMenuProps({ + items: props.items, + onSelect: (item: TItem) => { + props.command(item); + closeMenu(); + }, + editor, + range: props.range, + query: props.query, + }); + + const createRenderer = (props: SuggestionCallbackProps) => { + renderer = new ReactRenderer(config.component, { + editor, + props: buildMenuProps(props), + }); + document.body.appendChild(renderer.element); + }; + + return { + onStart: (props: SuggestionCallbackProps) => { + if (!props.clientRect || props.items.length === 0) { + return; + } + + createRenderer(props); + }, + onUpdate: (props: SuggestionCallbackProps) => { + 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(); + }, + }; +}; diff --git a/packages/twenty-front/src/modules/ui/suggestion/types/SuggestionMenuProps.ts b/packages/twenty-front/src/modules/ui/suggestion/types/SuggestionMenuProps.ts new file mode 100644 index 00000000000..d9faeeb8063 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/suggestion/types/SuggestionMenuProps.ts @@ -0,0 +1,15 @@ +import type { Editor, Range } from '@tiptap/core'; +import type { ReactNode } from 'react'; + +export type SuggestionMenuProps = { + 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; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts b/packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts index 72850dff2e7..05dda8cc338 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/tools/view-tools.factory.ts @@ -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 { + 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,