Test
'); + expect(paragraphCommand?.getIsActive(editor)).toBe(true); + }); + + it('should not be active when heading is active', () => { + const paragraphCommand = DEFAULT_SLASH_COMMANDS.find( + (cmd) => cmd.id === 'paragraph', + ); + + editor.commands.setContent('Test
'); + editor.commands.focus(); + + const range = { from: 0, to: 0 }; + const onSelect = h2Command?.getOnSelect(editor, range); + onSelect?.(); + + expect(editor.isActive('heading', { level: 2 })).toBe(true); + }); + + it('should execute list command correctly', () => { + const bulletListCommand = DEFAULT_SLASH_COMMANDS.find( + (cmd) => cmd.id === 'bulletList', + ); + + editor.commands.setContent('Test
'); + editor.commands.focus(); + + const range = { from: 0, to: 0 }; + const onSelect = bulletListCommand?.getOnSelect(editor, range); + onSelect?.(); + + expect(editor.isActive('bulletList')).toBe(true); + }); + }); + + describe('Search keywords', () => { + it('should include relevant keywords for each command', () => { + const paragraphCommand = DEFAULT_SLASH_COMMANDS.find( + (cmd) => cmd.id === 'paragraph', + ); + + expect(paragraphCommand?.keywords.length).toBeGreaterThan(0); + }); + + it('should have heading keywords for heading commands', () => { + const h1Command = DEFAULT_SLASH_COMMANDS.find((cmd) => cmd.id === 'h1'); + + // Should have keywords array + expect(h1Command?.keywords.length).toBeGreaterThan(0); + }); + + it('should have list keywords for list commands', () => { + const bulletListCommand = DEFAULT_SLASH_COMMANDS.find( + (cmd) => cmd.id === 'bulletList', + ); + const orderedListCommand = DEFAULT_SLASH_COMMANDS.find( + (cmd) => cmd.id === 'orderedList', + ); + + expect(bulletListCommand?.keywords.length).toBeGreaterThan(0); + expect(orderedListCommand?.keywords.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/advanced-text-editor/extensions/slash-command/__tests__/SlashCommand.test.ts b/packages/twenty-front/src/modules/advanced-text-editor/extensions/slash-command/__tests__/SlashCommand.test.ts new file mode 100644 index 0000000000..8acbc68a55 --- /dev/null +++ b/packages/twenty-front/src/modules/advanced-text-editor/extensions/slash-command/__tests__/SlashCommand.test.ts @@ -0,0 +1,148 @@ +import { i18n } from '@lingui/core'; +import { Editor } from '@tiptap/core'; +import { Document } from '@tiptap/extension-document'; +import { Heading } from '@tiptap/extension-heading'; +import { ListKit } from '@tiptap/extension-list'; +import { Paragraph } from '@tiptap/extension-paragraph'; +import { Text } from '@tiptap/extension-text'; + +import { SlashCommand } from '../SlashCommand'; + +describe('SlashCommand', () => { + let editor: Editor; + + beforeEach(() => { + // Initialize i18n for tests + i18n.load('en', {}); + i18n.activate('en'); + + editor = new Editor({ + extensions: [ + Document, + Paragraph, + Text, + Heading.configure({ levels: [1, 2, 3] }), + ListKit, + SlashCommand, + ], + content: '', + }); + }); + + afterEach(() => { + editor?.destroy(); + }); + + describe('Slash command trigger', () => { + it('should trigger suggestions when typing /', () => { + editor.commands.setContent(''); + editor.commands.focus(); + + // Type the slash character + editor.commands.insertContent('/'); + + // The suggestion plugin should be active + // We verify by checking if typing continues to work + expect(editor.getText()).toBe('/'); + }); + + it('should filter commands based on query', () => { + editor.commands.setContent(''); + editor.commands.focus(); + + // Type slash and partial command + editor.commands.insertContent('/head'); + + // Content should include the typed text + expect(editor.getText()).toContain('head'); + }); + }); + + describe('Command execution', () => { + it('should convert to heading 1 when command is executed', () => { + editor.commands.setContent('Test
'); + editor.commands.focus(); + + // Simulate selecting heading 1 command + editor.chain().focus().setHeading({ level: 1 }).run(); + + expect(editor.isActive('heading', { level: 1 })).toBe(true); + }); + + it('should convert to heading 2 when command is executed', () => { + editor.commands.setContent('Test
'); + editor.commands.focus(); + + editor.chain().focus().setHeading({ level: 2 }).run(); + + expect(editor.isActive('heading', { level: 2 })).toBe(true); + }); + + it('should convert to heading 3 when command is executed', () => { + editor.commands.setContent('Test
'); + editor.commands.focus(); + + editor.chain().focus().setHeading({ level: 3 }).run(); + + expect(editor.isActive('heading', { level: 3 })).toBe(true); + }); + + it('should convert to bullet list when command is executed', () => { + editor.commands.setContent('Test
'); + editor.commands.focus(); + + editor.chain().focus().toggleBulletList().run(); + + expect(editor.isActive('bulletList')).toBe(true); + }); + + it('should convert to ordered list when command is executed', () => { + editor.commands.setContent('Test
'); + editor.commands.focus(); + + editor.chain().focus().toggleOrderedList().run(); + + expect(editor.isActive('orderedList')).toBe(true); + }); + + it('should convert back to paragraph when command is executed', () => { + editor.commands.setContent('Test
', + editable: false, + }); + + // Verify editor is not editable + expect(readonlyEditor.isEditable).toBe(false); + + readonlyEditor.destroy(); + }); + }); +}); 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 new file mode 100644 index 0000000000..c0d188cc60 --- /dev/null +++ b/packages/twenty-front/src/modules/advanced-text-editor/extensions/slash-command/__tests__/SlashCommandRenderer.test.ts @@ -0,0 +1,327 @@ +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 '../SlashCommand'; +import { SlashCommandRenderer } from '../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/advanced-text-editor/hooks/useAdvancedTextEditor.ts b/packages/twenty-front/src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts index 221d711136..f4593038ad 100644 --- a/packages/twenty-front/src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts +++ b/packages/twenty-front/src/modules/advanced-text-editor/hooks/useAdvancedTextEditor.ts @@ -1,7 +1,9 @@ import { ResizableImage } from '@/advanced-text-editor/extensions/resizable-image/ResizableImage'; import { UploadImageExtension } from '@/advanced-text-editor/extensions/resizable-image/UploadImageExtension'; +import { SlashCommand } from '@/advanced-text-editor/extensions/slash-command/SlashCommand'; import { getInitialAdvancedTextEditorContent } from '@/workflow/workflow-variables/utils/getInitialAdvancedTextEditorContent'; import { VariableTag } from '@/workflow/workflow-variables/utils/variableTag'; +import { t } from '@lingui/core/macro'; import { Bold } from '@tiptap/extension-bold'; import { Document } from '@tiptap/extension-document'; import { HardBreak } from '@tiptap/extension-hard-break'; @@ -27,6 +29,7 @@ type UseAdvancedTextEditorProps = { onBlur?: (editor: Editor) => void; onImageUpload?: (file: File) => Promise