diff --git a/app/components/post_draft/quick_actions/input_quick_action/index.test.tsx b/app/components/post_draft/quick_actions/input_quick_action/index.test.tsx index b1f66534b..7f7b94fa9 100644 --- a/app/components/post_draft/quick_actions/input_quick_action/index.test.tsx +++ b/app/components/post_draft/quick_actions/input_quick_action/index.test.tsx @@ -1,55 +1,337 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. +import {useKeyboardAnimationContext} from '@context/keyboard_animation'; import {fireEvent, renderWithIntlAndTheme} from '@test/intl-test-helper'; import InputQuickAction from '.'; +jest.mock('@context/keyboard_animation', () => ({ + useKeyboardAnimationContext: jest.fn(), +})); + +jest.mock('@hooks/useFocusAfterEmojiDismiss', () => ({ + useFocusAfterEmojiDismiss: jest.fn((inputRef, focusInput) => ({ + focus: focusInput, + isDismissingEmojiPicker: {current: false}, + focusTimeoutRef: {current: null}, + isManuallyFocusingAfterEmojiDismiss: false, + })), +})); + describe('InputQuickAction', () => { - it('should add If theres existing text and it doesnt end with a space, add a space before @', () => { - const updateValue = jest.fn(); - const testID = 'test-id'; - const inputType = 'at'; - const focus = jest.fn(); + const mockUseKeyboardAnimationContext = jest.mocked(useKeyboardAnimationContext); + const mockUpdateCursorPosition = jest.fn(); + const mockInputRef = {current: undefined}; - const {getByTestId} = renderWithIntlAndTheme( - ); - - const icon = getByTestId('test-id'); - fireEvent.press(icon); - - expect(updateValue).toHaveBeenCalledWith(expect.any(Function)); - const updateFunction = updateValue.mock.calls[0][0]; - expect(updateFunction('')).toBe('@'); - expect(focus).toHaveBeenCalled(); + beforeEach(() => { + jest.clearAllMocks(); }); - it('should add space before @ if there is existing text and it doesnt end with a space', () => { - const updateValue = jest.fn(); - const testID = 'test-id'; - const inputType = 'at'; - const focus = jest.fn(); + describe('fallback behavior (no cursor position context)', () => { + beforeEach(() => { + mockUseKeyboardAnimationContext.mockReturnValue({ + inputRef: mockInputRef, + cursorPositionRef: undefined, + updateCursorPosition: undefined, + } as unknown as ReturnType); + }); - const {getByTestId} = renderWithIntlAndTheme( - ); + it('should add @ to empty string', () => { + const updateValue = jest.fn(); + const testID = 'test-id'; + const inputType = 'at'; + const focus = jest.fn(); - const icon = getByTestId('test-id'); - fireEvent.press(icon); + const {getByTestId} = renderWithIntlAndTheme( + ); - expect(updateValue).toHaveBeenCalledWith(expect.any(Function)); - const updateFunction = updateValue.mock.calls[0][0]; - expect(updateFunction('Hello')).toBe('Hello @'); - expect(focus).toHaveBeenCalled(); + const icon = getByTestId('test-id'); + fireEvent.press(icon); + + expect(updateValue).toHaveBeenCalledWith(expect.any(Function)); + const updateFunction = updateValue.mock.calls[0][0]; + expect(updateFunction('')).toBe('@'); + expect(focus).toHaveBeenCalled(); + }); + + it('should add space before @ if there is existing text and it doesnt end with a space', () => { + const updateValue = jest.fn(); + const testID = 'test-id'; + const inputType = 'at'; + const focus = jest.fn(); + + const {getByTestId} = renderWithIntlAndTheme( + ); + + const icon = getByTestId('test-id'); + fireEvent.press(icon); + + expect(updateValue).toHaveBeenCalledWith(expect.any(Function)); + const updateFunction = updateValue.mock.calls[0][0]; + expect(updateFunction('Hello')).toBe('Hello @'); + expect(focus).toHaveBeenCalled(); + }); }); + describe('cursor position insertion', () => { + let cursorPositionRef: {current: number}; + + beforeEach(() => { + cursorPositionRef = {current: 0}; + mockUseKeyboardAnimationContext.mockReturnValue({ + inputRef: mockInputRef, + cursorPositionRef, + updateCursorPosition: mockUpdateCursorPosition, + } as unknown as ReturnType); + }); + + describe('@ input type', () => { + it('should insert @ at cursor position at the beginning', () => { + const updateValue = jest.fn(); + const testID = 'test-id'; + const inputType = 'at'; + const focus = jest.fn(); + cursorPositionRef.current = 0; + + const {getByTestId} = renderWithIntlAndTheme( + ); + + const icon = getByTestId('test-id'); + fireEvent.press(icon); + + expect(updateValue).toHaveBeenCalledWith(expect.any(Function)); + const updateFunction = updateValue.mock.calls[0][0]; + expect(updateFunction('Hello')).toBe('@Hello'); + expect(cursorPositionRef.current).toBe(1); + expect(mockUpdateCursorPosition).toHaveBeenCalledWith(1); + expect(focus).toHaveBeenCalled(); + }); + + it('should insert @ at cursor position in the middle', () => { + const updateValue = jest.fn(); + const testID = 'test-id'; + const inputType = 'at'; + const focus = jest.fn(); + cursorPositionRef.current = 3; + + const {getByTestId} = renderWithIntlAndTheme( + ); + + const icon = getByTestId('test-id'); + fireEvent.press(icon); + + expect(updateValue).toHaveBeenCalledWith(expect.any(Function)); + const updateFunction = updateValue.mock.calls[0][0]; + expect(updateFunction('Hello')).toBe('Hel@lo'); + expect(cursorPositionRef.current).toBe(4); + expect(mockUpdateCursorPosition).toHaveBeenCalledWith(4); + expect(focus).toHaveBeenCalled(); + }); + + it('should insert @ at cursor position at the end', () => { + const updateValue = jest.fn(); + const testID = 'test-id'; + const inputType = 'at'; + const focus = jest.fn(); + cursorPositionRef.current = 5; + + const {getByTestId} = renderWithIntlAndTheme( + ); + + const icon = getByTestId('test-id'); + fireEvent.press(icon); + + expect(updateValue).toHaveBeenCalledWith(expect.any(Function)); + const updateFunction = updateValue.mock.calls[0][0]; + expect(updateFunction('Hello')).toBe('Hello @'); + expect(cursorPositionRef.current).toBe(7); + expect(mockUpdateCursorPosition).toHaveBeenCalledWith(7); + expect(focus).toHaveBeenCalled(); + }); + + it('should add space before @ when cursor is at the end and previous char is not space', () => { + const updateValue = jest.fn(); + const testID = 'test-id'; + const inputType = 'at'; + const focus = jest.fn(); + cursorPositionRef.current = 5; + + const {getByTestId} = renderWithIntlAndTheme( + ); + + const icon = getByTestId('test-id'); + fireEvent.press(icon); + + expect(updateValue).toHaveBeenCalledWith(expect.any(Function)); + const updateFunction = updateValue.mock.calls[0][0]; + expect(updateFunction('Hello')).toBe('Hello @'); + expect(cursorPositionRef.current).toBe(7); + expect(mockUpdateCursorPosition).toHaveBeenCalledWith(7); + expect(focus).toHaveBeenCalled(); + }); + + it('should not add space before @ when cursor is at the end and previous char is space', () => { + const updateValue = jest.fn(); + const testID = 'test-id'; + const inputType = 'at'; + const focus = jest.fn(); + cursorPositionRef.current = 6; + + const {getByTestId} = renderWithIntlAndTheme( + ); + + const icon = getByTestId('test-id'); + fireEvent.press(icon); + + expect(updateValue).toHaveBeenCalledWith(expect.any(Function)); + const updateFunction = updateValue.mock.calls[0][0]; + expect(updateFunction('Hello ')).toBe('Hello @'); + expect(cursorPositionRef.current).toBe(7); + expect(mockUpdateCursorPosition).toHaveBeenCalledWith(7); + expect(focus).toHaveBeenCalled(); + }); + + it('should not add space before @ when cursor is at the beginning', () => { + const updateValue = jest.fn(); + const testID = 'test-id'; + const inputType = 'at'; + const focus = jest.fn(); + cursorPositionRef.current = 0; + + const {getByTestId} = renderWithIntlAndTheme( + ); + + const icon = getByTestId('test-id'); + fireEvent.press(icon); + + expect(updateValue).toHaveBeenCalledWith(expect.any(Function)); + const updateFunction = updateValue.mock.calls[0][0]; + expect(updateFunction('Hello')).toBe('@Hello'); + expect(cursorPositionRef.current).toBe(1); + expect(mockUpdateCursorPosition).toHaveBeenCalledWith(1); + expect(focus).toHaveBeenCalled(); + }); + }); + + describe('slash input type', () => { + it('should insert / at cursor position at the beginning', () => { + const updateValue = jest.fn(); + const testID = 'test-id'; + const inputType = 'slash'; + const focus = jest.fn(); + cursorPositionRef.current = 0; + + const {getByTestId} = renderWithIntlAndTheme( + ); + + const icon = getByTestId('test-id'); + fireEvent.press(icon); + + expect(updateValue).toHaveBeenCalledWith(expect.any(Function)); + const updateFunction = updateValue.mock.calls[0][0]; + expect(updateFunction('Hello')).toBe('/Hello'); + expect(cursorPositionRef.current).toBe(1); + expect(mockUpdateCursorPosition).toHaveBeenCalledWith(1); + expect(focus).toHaveBeenCalled(); + }); + + it('should insert / at cursor position in the middle', () => { + const updateValue = jest.fn(); + const testID = 'test-id'; + const inputType = 'slash'; + const focus = jest.fn(); + cursorPositionRef.current = 3; + + const {getByTestId} = renderWithIntlAndTheme( + ); + + const icon = getByTestId('test-id'); + fireEvent.press(icon); + + expect(updateValue).toHaveBeenCalledWith(expect.any(Function)); + const updateFunction = updateValue.mock.calls[0][0]; + expect(updateFunction('Hello')).toBe('Hel/lo'); + expect(cursorPositionRef.current).toBe(4); + expect(mockUpdateCursorPosition).toHaveBeenCalledWith(4); + expect(focus).toHaveBeenCalled(); + }); + + it('should insert / at cursor position at the end', () => { + const updateValue = jest.fn(); + const testID = 'test-id'; + const inputType = 'slash'; + const focus = jest.fn(); + cursorPositionRef.current = 5; + + const {getByTestId} = renderWithIntlAndTheme( + ); + + const icon = getByTestId('test-id'); + fireEvent.press(icon); + + expect(updateValue).toHaveBeenCalledWith(expect.any(Function)); + const updateFunction = updateValue.mock.calls[0][0]; + expect(updateFunction('Hello')).toBe('Hello/'); + expect(cursorPositionRef.current).toBe(6); + expect(mockUpdateCursorPosition).toHaveBeenCalledWith(6); + expect(focus).toHaveBeenCalled(); + }); + }); + }); }); diff --git a/app/components/post_draft/quick_actions/input_quick_action/index.tsx b/app/components/post_draft/quick_actions/input_quick_action/index.tsx index 7175ac73d..5b9f49ab0 100644 --- a/app/components/post_draft/quick_actions/input_quick_action/index.tsx +++ b/app/components/post_draft/quick_actions/input_quick_action/index.tsx @@ -40,24 +40,54 @@ export default function InputQuickAction({ focus, }: Props) { const theme = useTheme(); - const {inputRef} = useKeyboardAnimationContext(); + const {inputRef, cursorPositionRef, updateCursorPosition} = useKeyboardAnimationContext(); // Use hook to handle focus after emoji picker dismissal const {focus: focusWithEmojiDismiss} = useFocusAfterEmojiDismiss(inputRef, focus); const onPress = useCallback(() => { - updateValue((v) => { - if (inputType === 'at') { - // If there's existing text and it doesn't end with a space, add a space before @ - if (v.length > 0 && !v.endsWith(' ')) { - return `${v} @`; + if (cursorPositionRef && updateCursorPosition) { + const currentCursorPosition = cursorPositionRef.current; + + updateValue((v) => { + if (inputType === 'at') { + let insertedText = '@'; + const charBeforeCursor = currentCursorPosition > 0 ? v[currentCursorPosition - 1] : ''; + + if (currentCursorPosition === v.length && currentCursorPosition > 0 && charBeforeCursor !== ' ') { + insertedText = ' @'; + } + + const newValue = v.slice(0, currentCursorPosition) + insertedText + v.slice(currentCursorPosition); + const newCursorPosition = currentCursorPosition + insertedText.length; + + cursorPositionRef.current = newCursorPosition; + updateCursorPosition(newCursorPosition); + + return newValue; } - return `${v}@`; - } - return '/'; - }); + + const newValue = v.slice(0, currentCursorPosition) + '/' + v.slice(currentCursorPosition); + const newCursorPosition = currentCursorPosition + 1; + + cursorPositionRef.current = newCursorPosition; + updateCursorPosition(newCursorPosition); + + return newValue; + }); + } else { + updateValue((v) => { + if (inputType === 'at') { + if (v.length > 0 && !v.endsWith(' ')) { + return `${v} @`; + } + return `${v}@`; + } + return '/'; + }); + } focusWithEmojiDismiss(); - }, [inputType, updateValue, focusWithEmojiDismiss]); + }, [inputType, updateValue, focusWithEmojiDismiss, cursorPositionRef, updateCursorPosition]); const actionTestID = disabled ? `${testID}.disabled` :