MM-52173 Add setting to delay channel autocomplete (#22952)

* Add unit tests for ChannelMentionProvider.handleCompleteWord

* Add a minimum length before the ChannelMentionProvider triggers

* MM-52173 Move delayed autcomplete behind a setting

* Fix e2e test type check

* Update Textbox tests

* Add new setting to server telemetry
Этот коммит содержится в:
Harrison Healey
2023-05-02 10:11:15 -04:00
коммит произвёл GitHub
родитель 609ab9c765
Коммит 3ba75afa08
13 изменённых файлов: 271 добавлений и 6 удалений

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

@@ -62,6 +62,7 @@ exports[`components/TextBox should match snapshot with additional, optional prop
},
ChannelMentionProvider {
"autocompleteChannels": [MockFunction],
"delayChannelAutocomplete": false,
"disableDispatches": false,
"forceDispatch": false,
"lastCompletedWord": "",
@@ -174,6 +175,7 @@ exports[`components/TextBox should match snapshot with required props 1`] = `
},
ChannelMentionProvider {
"autocompleteChannels": [MockFunction],
"delayChannelAutocomplete": false,
"disableDispatches": false,
"forceDispatch": false,
"lastCompletedWord": "",
@@ -267,6 +269,7 @@ exports[`components/TextBox should throw error when new property is too long 1`]
},
ChannelMentionProvider {
"autocompleteChannels": [MockFunction],
"delayChannelAutocomplete": false,
"disableDispatches": false,
"forceDispatch": false,
"lastCompletedWord": "",
@@ -360,6 +363,7 @@ exports[`components/TextBox should throw error when value is too long 1`] = `
},
ChannelMentionProvider {
"autocompleteChannels": [MockFunction],
"delayChannelAutocomplete": false,
"disableDispatches": false,
"forceDispatch": false,
"lastCompletedWord": "",

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

@@ -6896,6 +6896,15 @@ const AdminDefinition = {
help_text_default: 'When true, Mattermost will not refetch channels and channel members when the browser regains focus. This may result in improved performance for users with many channels and channel members.',
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
{
type: Constants.SettingsTypes.TYPE_BOOL,
key: 'ExperimentalSettings.DelayChannelAutocomplete',
label: t('admin.experimental.delayChannelAutocomplete.title'),
label_default: 'Delay Channel Autocomplete:',
help_text: t('admin.experimental.delayChannelAutocomplete.desc'),
help_text_default: 'When true, the autocomplete for channel links (such as ~town-square) will only trigger after typing a tilde followed by a couple letters. When false, the autocomplete will appear as soon as the user types a tilde.',
isDisabled: it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.EXPERIMENTAL.FEATURES)),
},
],
},
},

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

@@ -0,0 +1,214 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
jest.mock('mattermost-redux/selectors/entities/channels', () => ({
getMyChannels: jest.fn(() => []),
getMyChannelMemberships: jest.fn(() => {}),
}));
jest.mock('stores/redux_store');
import ChannelMentionProvider from './channel_mention_provider';
describe('ChannelMentionProvider.handlePretextChanged', () => {
const autocompleteChannels = jest.fn();
const resultsCallback = jest.fn();
let provider: ChannelMentionProvider;
beforeEach(() => {
provider = new ChannelMentionProvider(autocompleteChannels, false);
});
describe('basic cases', () => {
test('should not match empty string', () => {
const matched = provider.handlePretextChanged('', resultsCallback);
expect(matched).toBe(false);
expect(autocompleteChannels).not.toHaveBeenCalled();
expect(resultsCallback).not.toHaveBeenCalled();
});
test('should not match plain text', () => {
const matched = provider.handlePretextChanged('this is a test', resultsCallback);
expect(matched).toBe(false);
expect(autocompleteChannels).not.toHaveBeenCalled();
expect(resultsCallback).not.toHaveBeenCalled();
});
test('should match a partial channel\'s name', () => {
const matched = provider.handlePretextChanged('~town-sq', resultsCallback);
expect(matched).toBe(true);
expect(autocompleteChannels).toHaveBeenCalledWith('town-sq', expect.anything(), expect.anything());
expect(resultsCallback).toHaveBeenCalled();
});
test('should match a channel\'s name', () => {
const matched = provider.handlePretextChanged('~town-square', resultsCallback);
expect(matched).toBe(true);
expect(autocompleteChannels).toHaveBeenCalledWith('town-square', expect.anything(), expect.anything());
expect(resultsCallback).toHaveBeenCalled();
});
test('should match a channel\'s partial display name', () => {
const matched = provider.handlePretextChanged('~Town Sq', resultsCallback);
expect(matched).toBe(true);
expect(autocompleteChannels).toHaveBeenCalledWith('town sq', expect.anything(), expect.anything());
expect(resultsCallback).toHaveBeenCalled();
});
test('should match a channel\'s display name', () => {
const matched = provider.handlePretextChanged('~Town Square', resultsCallback);
expect(matched).toBe(true);
expect(autocompleteChannels).toHaveBeenCalledWith('town square', expect.anything(), expect.anything());
expect(resultsCallback).toHaveBeenCalled();
});
test('should match part of the text', () => {
const matched = provider.handlePretextChanged('this is ~town-squ', resultsCallback);
expect(matched).toBe(true);
expect(autocompleteChannels).toHaveBeenCalledWith('town-squ', expect.anything(), expect.anything());
expect(resultsCallback).toHaveBeenCalled();
});
test('should only match at the end of the text', () => {
const matched = provider.handlePretextChanged('this is ~town-square, not ~off-topic', resultsCallback);
expect(matched).toBe(true);
expect(autocompleteChannels).toHaveBeenCalledWith('off-topic', expect.anything(), expect.anything());
expect(resultsCallback).toHaveBeenCalled();
});
test('should lower case search term', () => {
const matched = provider.handlePretextChanged('this is ~town SQUARE ', resultsCallback);
expect(matched).toBe(true);
expect(autocompleteChannels).toHaveBeenCalledWith('town square ', expect.anything(), expect.anything());
expect(resultsCallback).toHaveBeenCalled();
});
});
describe('strikethrough text', () => {
test('should not match the start of strikethrough text', () => {
const matched = provider.handlePretextChanged('~~', resultsCallback);
expect(matched).toBe(false);
expect(autocompleteChannels).not.toHaveBeenCalled();
expect(resultsCallback).not.toHaveBeenCalled();
});
test('should not match the middle of strikethrough text', () => {
const matched = provider.handlePretextChanged('~~town square', resultsCallback);
expect(matched).toBe(false);
expect(autocompleteChannels).not.toHaveBeenCalled();
expect(resultsCallback).not.toHaveBeenCalled();
});
test('should not match the end of strikethrough text', () => {
const matched = provider.handlePretextChanged('~~this is a test~~', resultsCallback);
expect(matched).toBe(false);
expect(autocompleteChannels).not.toHaveBeenCalled();
expect(resultsCallback).not.toHaveBeenCalled();
});
});
describe('matching text after completing a result', () => {
test('should not continue to match a link that was just completed', () => {
provider.handleCompleteWord('~town-square');
const matched = provider.handlePretextChanged('This is ~town-square', resultsCallback);
expect(matched).toBe(false);
expect(autocompleteChannels).not.toHaveBeenCalled();
expect(resultsCallback).not.toHaveBeenCalled();
});
test('should not continue to match a link that was completed, even after typing more text', () => {
provider.handleCompleteWord('~town-square');
const matched = provider.handlePretextChanged('This is ~town-square and a test', resultsCallback);
expect(matched).toBe(false);
expect(autocompleteChannels).not.toHaveBeenCalled();
expect(resultsCallback).not.toHaveBeenCalled();
});
test('should start matching input again after another link is started', () => {
provider.handleCompleteWord('~town-square');
const matched = provider.handlePretextChanged('This is ~town-square and not ~off', resultsCallback);
expect(matched).toBe(true);
expect(autocompleteChannels).toHaveBeenCalledWith('off', expect.anything(), expect.anything());
expect(resultsCallback).toHaveBeenCalled();
});
});
test('should not continue to match after receiving no results until another possible link starts', () => {
autocompleteChannels.mockImplementationOnce((prefix, success) => {
success([]);
});
let matched = provider.handlePretextChanged('This is ~no-results', resultsCallback);
expect(matched).toBe(true);
expect(autocompleteChannels).toHaveBeenCalledWith('no-results', expect.anything(), expect.anything());
expect(resultsCallback).toHaveBeenCalledTimes(2);
autocompleteChannels.mockReset();
resultsCallback.mockReset();
matched = provider.handlePretextChanged('This is ~no-results in a test', resultsCallback);
expect(matched).toBe(false);
expect(autocompleteChannels).not.toHaveBeenCalled();
expect(resultsCallback).not.toHaveBeenCalled();
matched = provider.handlePretextChanged('This is ~no-results in a test using ~town', resultsCallback);
expect(matched).toBe(true);
expect(autocompleteChannels).toHaveBeenCalledWith('town', expect.anything(), expect.anything());
expect(resultsCallback).toHaveBeenCalled();
});
describe('delayed autocomplete', () => {
test('with the setting enabled, should not match a link shorter than the minimum length', () => {
provider.setProps({delayChannelAutocomplete: true});
let matched = provider.handlePretextChanged('~', resultsCallback);
expect(matched).toBe(false);
expect(autocompleteChannels).not.toHaveBeenCalled();
expect(resultsCallback).not.toHaveBeenCalled();
matched = provider.handlePretextChanged('~t', resultsCallback);
expect(matched).toBe(false);
expect(autocompleteChannels).not.toHaveBeenCalled();
expect(resultsCallback).not.toHaveBeenCalled();
matched = provider.handlePretextChanged('~to', resultsCallback);
expect(matched).toBe(true);
expect(autocompleteChannels).toHaveBeenCalledWith('to', expect.anything(), expect.anything());
expect(resultsCallback).toHaveBeenCalled();
});
test('with the setting disabled, should match a link shorter than the minimum length', () => {
provider.setProps({delayChannelAutocomplete: false});
const matched = provider.handlePretextChanged('~', resultsCallback);
expect(matched).toBe(true);
expect(autocompleteChannels).toHaveBeenCalledWith('', expect.anything(), expect.anything());
expect(resultsCallback).toHaveBeenCalled();
});
});
});

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

@@ -17,6 +17,8 @@ import {Constants} from 'utils/constants';
import Provider from './provider';
import Suggestion from './suggestion.jsx';
export const MIN_CHANNEL_LINK_LENGTH = 2;
export type Results = {
matchedPretext: string;
terms: string[];
@@ -81,12 +83,14 @@ export class ChannelMentionSuggestion extends Suggestion {
}
export default class ChannelMentionProvider extends Provider {
lastPrefixTrimmed: string;
lastPrefixWithNoResults: string;
lastCompletedWord: string;
private lastPrefixTrimmed: string;
private lastPrefixWithNoResults: string;
private lastCompletedWord: string;
triggerCharacter: string;
private delayChannelAutocomplete: boolean;
autocompleteChannels: (term: string, success: (channels: Channel[]) => void, error: () => void) => Promise<ActionResult>;
constructor(channelSearchFunc: (term: string, success: (channels: Channel[]) => void, error: () => void) => Promise<ActionResult>) {
constructor(channelSearchFunc: (term: string, success: (channels: Channel[]) => void, error: () => void) => Promise<ActionResult>, delayChannelAutocomplete: boolean) {
super();
this.lastPrefixTrimmed = '';
@@ -95,6 +99,11 @@ export default class ChannelMentionProvider extends Provider {
this.triggerCharacter = '~';
this.autocompleteChannels = channelSearchFunc;
this.delayChannelAutocomplete = delayChannelAutocomplete;
}
setProps(props: {delayChannelAutocomplete: boolean}) {
this.delayChannelAutocomplete = props.delayChannelAutocomplete;
}
handlePretextChanged(pretext: string, resultCallback: ResultsCallback) {
@@ -114,6 +123,10 @@ export default class ChannelMentionProvider extends Provider {
const prefix = captured[2];
if (this.delayChannelAutocomplete && prefix.length < MIN_CHANNEL_LINK_LENGTH) {
return false;
}
if (this.lastPrefixTrimmed && prefix.trim() === this.lastPrefixTrimmed) {
// Don't keep searching if the user keeps typing spaces
return true;

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

@@ -16,6 +16,7 @@ describe('components/TextBox', () => {
{id: 'id1'},
{id: 'id2'},
],
delayChannelAutocomplete: false,
autocompleteGroups: [
{id: 'gid1'},
{id: 'gid2'},

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

@@ -5,7 +5,7 @@ import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {connect} from 'react-redux';
import {getAssociatedGroupsForReference} from 'mattermost-redux/selectors/entities/groups';
import {getLicense} from 'mattermost-redux/selectors/entities/general';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {getCurrentTeamId} from 'mattermost-redux/selectors/entities/teams';
import {makeGetProfilesForThread} from 'mattermost-redux/selectors/entities/posts';
@@ -47,6 +47,7 @@ const makeMapStateToProps = () => {
currentTeamId: teamId,
autocompleteGroups,
priorityProfiles: getProfilesForThread(state, ownProps.rootId ?? ''),
delayChannelAutocomplete: getConfig(state).DelayChannelAutocomplete === 'true',
};
};
};

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

@@ -61,6 +61,7 @@ export type Props = {
currentTeamId: string;
preview?: boolean;
autocompleteGroups: Array<{ id: string }> | null;
delayChannelAutocomplete: boolean;
actions: {
autocompleteUsersInChannel: (prefix: string, channelId: string) => Promise<ActionResult>;
autocompleteChannels: (term: string, success: (channels: Channel[]) => void, error: () => void) => Promise<ActionResult>;
@@ -113,7 +114,7 @@ export default class Textbox extends React.PureComponent<Props> {
searchAssociatedGroupsForReference: (prefix: string) => this.props.actions.searchAssociatedGroupsForReference(prefix, this.props.currentTeamId, this.props.channelId),
priorityProfiles: this.props.priorityProfiles,
}),
new ChannelMentionProvider(props.actions.autocompleteChannels),
new ChannelMentionProvider(props.actions.autocompleteChannels, props.delayChannelAutocomplete),
new EmoticonProvider(),
);
@@ -180,6 +181,16 @@ export default class Textbox extends React.PureComponent<Props> {
}
}
if (this.props.delayChannelAutocomplete !== prevProps.delayChannelAutocomplete) {
for (const provider of this.suggestionProviders) {
if (provider instanceof ChannelMentionProvider) {
provider.setProps({
delayChannelAutocomplete: this.props.delayChannelAutocomplete,
});
}
}
}
if (prevProps.value !== this.props.value) {
this.checkMessageLength(this.props.value);
}

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

@@ -895,6 +895,8 @@
"admin.experimental.collapsedThreads.title": "Collapsed Reply Threads",
"admin.experimental.defaultTheme.desc": "Set a default theme that applies to all new users on the system.",
"admin.experimental.defaultTheme.title": "Default Theme:",
"admin.experimental.delayChannelAutocomplete.desc": "When true, the autocomplete for channel links (such as ~town-square) will only trigger after typing a tilde followed by a couple letters. When false, the autocomplete will appear as soon as the user types a tilde.",
"admin.experimental.delayChannelAutocomplete.title": "Delay Channel Autocomplete:",
"admin.experimental.disableRefetchingOnBrowserFocus.desc": "When true, Mattermost will not refetch channels and channel members when the browser regains focus. This may result in improved performance for users with many channels and channel members.",
"admin.experimental.disableRefetchingOnBrowserFocus.title": "Disable data refetching on browser refocus:",
"admin.experimental.emailBatchingBufferSize.desc": "Specify the maximum number of notifications batched into a single email.",

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

@@ -196,6 +196,7 @@ export type ClientConfig = {
PostPriority: string;
ReduceOnBoardingTaskList: string;
PostAcknowledgements: string;
DelayChannelAutocomplete: 'true' | 'false';
};
export type License = {
@@ -730,6 +731,7 @@ export type ExperimentalSettings = {
EnableAppBar: boolean;
PatchPluginsReactDOM: boolean;
DisableRefetchingOnBrowserFocus: boolean;
DelayChannelAutocomplete: boolean;
};
export type AnalyticsSettings = {