MM-57320 Fix autocomplete occasionally erasing all text after caret (#26857)

* MM-57320 Fix autocomplete occasionally erasing all text after caret

* Change makeHandleReceivedSuggestionsAndComplete to only allow complete word to be called once

* Actually do what the last commit said
Этот коммит содержится в:
Harrison Healey
2024-04-30 17:22:54 -04:00
коммит произвёл GitHub
родитель b7e830f4a1
Коммит 43a5e61d85
2 изменённых файлов: 264 добавлений и 37 удалений

Просмотреть файл

@@ -248,6 +248,10 @@ export default class SuggestionBox extends React.PureComponent {
}
}
componentWillUnmount() {
clearTimeout(this.timeoutId);
}
getTextbox = () => {
if (!this.inputRef.current) {
return null;
@@ -387,15 +391,14 @@ export default class SuggestionBox extends React.PureComponent {
prefix = pretext.substring(0, pretext.length - overlap.length - matchedPretext.length);
}
const suffix = text.substring(caret);
let newValue;
if (keepPretext) {
newValue = pretext;
} else {
newValue = prefix + term + ' ' + suffix;
// The term no longer fits the pretext, so don't change anything or else we might erase something
return;
}
const suffix = text.substring(caret);
const newValue = prefix + term + ' ' + suffix;
textbox.value = newValue;
if (this.props.onChange) {
@@ -565,29 +568,6 @@ export default class SuggestionBox extends React.PureComponent {
return this.state.items.some((item) => !item.loading);
};
confirmPretext = () => {
const textbox = this.getTextbox();
const pretext = textbox.value.substring(0, textbox.selectionEnd).toLowerCase();
if (this.pretext !== pretext) {
this.handlePretextChanged(pretext);
}
};
handleKeyUp = (e) => {
this.confirmPretext();
if (this.props.onKeyUp) {
this.props.onKeyUp(e);
}
};
handleMouseUp = (e) => {
this.confirmPretext();
if (this.props.onMouseUp) {
this.props.onMouseUp(e);
}
};
handleKeyDown = (e) => {
if ((this.props.openWhenEmpty || this.props.value) && this.hasSuggestions()) {
const ctrlOrMetaKeyPressed = e.ctrlKey || e.metaKey;
@@ -685,11 +665,16 @@ export default class SuggestionBox extends React.PureComponent {
return {selection, matchedPretext: suggestions.matchedPretext};
};
handleReceivedSuggestionsAndComplete = (suggestions) => {
const {selection, matchedPretext} = this.handleReceivedSuggestions(suggestions);
if (selection) {
this.handleCompleteWord(selection, matchedPretext);
}
makeHandleReceivedSuggestionsAndComplete = () => {
let firstComplete = true;
return (suggestions) => {
const {selection, matchedPretext} = this.handleReceivedSuggestions(suggestions);
if (selection && firstComplete) {
this.handleCompleteWord(selection, matchedPretext);
firstComplete = false;
}
};
};
nonDebouncedPretextChanged = (pretext, complete = false) => {
@@ -698,7 +683,7 @@ export default class SuggestionBox extends React.PureComponent {
let handled = false;
let callback = this.handleReceivedSuggestions;
if (complete) {
callback = this.handleReceivedSuggestionsAndComplete;
callback = this.makeHandleReceivedSuggestionsAndComplete();
}
for (const provider of this.props.providers) {
handled = provider.handlePretextChanged(pretext, callback) || handled;
@@ -840,8 +825,6 @@ export default class SuggestionBox extends React.PureComponent {
onCompositionUpdate={this.handleCompositionUpdate}
onCompositionEnd={this.handleCompositionEnd}
onKeyDown={this.handleKeyDown}
onKeyUp={this.handleKeyUp}
onMouseUp={this.handleMouseUp}
/>
{(this.props.openWhenEmpty || this.props.value.length >= this.props.requiredCharacters) && this.state.presentationType === 'text' && (
<SuggestionListComponent

Просмотреть файл

@@ -0,0 +1,244 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils';
import SuggestionBox from './suggestion_box';
import type {ResultsCallback} from '../provider';
import Provider from '../provider';
import SuggestionList from '../suggestion_list';
function TestWrapper(props: React.ComponentPropsWithoutRef<typeof SuggestionBox>) {
// eslint-disable-next-line react/prop-types
const [value, setValue] = useState(props.value);
const handleChange = useCallback((e) => setValue(e.target.value), []);
return (
<SuggestionBox
{...props}
onChange={handleChange}
value={value}
/>
);
}
const TestSuggestion = React.forwardRef<HTMLDivElement, {term: string}>((props, ref) => {
return <div ref={ref}>{'Suggestion: ' + props.term}</div>;
});
class TestProvider extends Provider {
private repeatResults: boolean;
constructor(repeatResults = false) {
super();
this.repeatResults = repeatResults;
}
handlePretextChanged(pretext: string, resultCallback: ResultsCallback<string>) {
if (pretext.trim().length === 0) {
return false;
}
const terms = [pretext + pretext];
resultCallback({
matchedPretext: pretext,
terms,
items: terms,
component: TestSuggestion,
});
if (this.repeatResults) {
setTimeout(() => {
resultCallback({
matchedPretext: pretext,
terms,
items: terms,
component: TestSuggestion,
});
}, 10);
}
return true;
}
}
describe('SuggestionBox', () => {
function makeBaseProps(): React.ComponentProps<typeof SuggestionBox> {
return {
listComponent: SuggestionList,
value: '',
providers: [],
actions: {
addMessageIntoHistory: jest.fn(),
},
placeholder: 'test input',
};
}
test('should list suggestions based on typed text', async () => {
const provider = new TestProvider();
const providerSpy = jest.spyOn(provider, 'handlePretextChanged');
renderWithContext(
<TestWrapper
{...makeBaseProps()}
providers={[provider]}
/>,
);
// Start with no suggestions rendered
expect(screen.queryByRole('list')).not.toBeInTheDocument();
// Typing some text should cause a suggestion to be shown
userEvent.click(screen.getByPlaceholderText('test input'));
await userEvent.keyboard('test');
await waitFor(() => {
// Note that debouncing causes the provider to only be called once when the user stops typing
expect(providerSpy).toHaveBeenCalledTimes(1);
});
expect(screen.queryByRole('list')).toBeVisible();
expect(screen.queryByRole('list')).toBeVisible();
expect(screen.getByText('Suggestion: testtest')).toBeVisible();
// Typing more text should cause the suggestion to be updaetd
await userEvent.keyboard('words');
await waitFor(() => {
expect(providerSpy).toHaveBeenCalledTimes(2);
});
expect(screen.queryByRole('list')).toBeVisible();
expect(screen.getByText('Suggestion: testwordstestwords')).toBeVisible();
// Clearing the textbox hides all suggestions
await userEvent.clear(screen.getByPlaceholderText('test input'));
expect(screen.queryByRole('list')).not.toBeInTheDocument();
});
test('should hide suggestions on pressing escape', async () => {
const provider = new TestProvider();
renderWithContext(
<TestWrapper
{...makeBaseProps()}
providers={[provider]}
/>,
);
// Start with no suggestions rendered
expect(screen.queryByRole('list')).not.toBeInTheDocument();
// Typing some text should cause a suggestion to be shown
userEvent.click(screen.getByPlaceholderText('test input'));
await userEvent.keyboard('test');
await waitFor(() => {
expect(screen.getByRole('list')).toBeVisible();
});
// Pressing escape hides all suggestions
await userEvent.keyboard('{escape}');
expect(screen.queryByRole('list')).not.toBeInTheDocument();
});
test('should autocomplete suggestions by pressing enter', async () => {
const provider = new TestProvider();
renderWithContext(
<TestWrapper
{...makeBaseProps()}
providers={[provider]}
/>,
);
// Typing some text should cause a suggestion to be shown
userEvent.click(screen.getByPlaceholderText('test input'));
await userEvent.keyboard('test');
await waitFor(() => {
expect(screen.queryByRole('list')).toBeVisible();
expect(screen.getByText('Suggestion: testtest')).toBeVisible();
});
// Pressing enter should update the textbox value and hide the suggestion list
await userEvent.keyboard('{enter}');
await waitFor(() => {
expect(screen.getByPlaceholderText('test input')).toHaveValue('testtest ');
});
expect(screen.queryByRole('list')).not.toBeInTheDocument();
});
test('MM-57320 completing text with enter and calling resultCallback twice should not erase text following caret', async () => {
const provider = new TestProvider(true);
const onSuggestionsReceived = jest.fn();
renderWithContext(
<TestWrapper
{...makeBaseProps()}
providers={[provider]}
onSuggestionsReceived={onSuggestionsReceived}
/>,
);
userEvent.click(screen.getByPlaceholderText('test input'));
await userEvent.keyboard('This is important');
// The provider will send results to the SuggestionBox twice to simulate loading results from the server
await waitFor(() => {
expect(onSuggestionsReceived).toHaveBeenCalledTimes(2);
});
onSuggestionsReceived.mockClear();
expect(screen.getByPlaceholderText('test input')).toHaveValue('This is important');
expect(screen.getByRole('list')).toBeVisible();
expect(screen.getByText('Suggestion: This is importantThis is important')).toBeVisible();
// Move the caret back to the start of the textbox and then use escape to clear the suggestions because
// we don't support moving the caret with the autocomplete open yet
await userEvent.keyboard('{home}{escape}');
expect(screen.queryByRole('list')).not.toBeInTheDocument();
// Type a space and then start typing something again to show results
onSuggestionsReceived.mockClear();
await userEvent.keyboard('@us');
await waitFor(() => {
expect(onSuggestionsReceived).toHaveBeenCalledTimes(2);
});
expect(screen.getByRole('list')).toBeVisible();
expect(screen.getByText('Suggestion: @us@us')).toBeVisible();
onSuggestionsReceived.mockClear();
// Type some more and then hit enter before the second set of results is received
await userEvent.keyboard('e{enter}');
await waitFor(() => {
expect(onSuggestionsReceived).toHaveBeenCalledTimes(1);
});
expect(screen.getByPlaceholderText('test input')).toHaveValue('@use@use This is important');
// Wait for the second set of results has been received to ensure the contents of the textbox aren't lost
await new Promise((resolve) => setTimeout(resolve, 20));
// expect(onSuggestionsReceived).toHaveBeenCalledTimes(1);
expect(screen.getByPlaceholderText('test input')).toHaveValue('@use@use This is important');
});
});