At mentioned from quick action respect cursor position (#9425) (#9477)

* At mentioned from quick action respect cursor position

* Reverted the unnecessary changes

(cherry picked from commit 20a70ae64d)

Co-authored-by: Rajat Dabade <rajatdabade1997@gmail.com>
This commit is contained in:
Mattermost Build 2026-01-28 09:35:16 +02:00 committed by GitHub
parent fc6eeec6c7
commit 76031bf086
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 361 additions and 49 deletions

View file

@ -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(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
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<typeof useKeyboardAnimationContext>);
});
const {getByTestId} = renderWithIntlAndTheme(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
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(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
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(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
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<typeof useKeyboardAnimationContext>);
});
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(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
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(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
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(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
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(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
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(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
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(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
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(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
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(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
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(
<InputQuickAction
testID={testID}
updateValue={updateValue}
inputType={inputType}
focus={focus}
/>);
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();
});
});
});
});

View file

@ -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` :