[MM-34415, MM-34808, MM-34572, MM-34385, MM-32445, MM-31525] Handle user and channel provider from the app parser (#5503)
* Handle user and channel provider from the app parser * Do not process mapStateToProps on appsTakeOver * Fix tsc * Address feedback * Fix broken reference * Address feedback and fix tests * Fix i18n * Use instantiated teamID on parser context * Address feedback * Fix test * Address feedback Co-authored-by: Mattermod <mattermod@users.noreply.github.com>
This commit is contained in:
parent
0251a19aa9
commit
b7fcda3956
25 changed files with 902 additions and 253 deletions
|
|
@ -174,7 +174,7 @@ describe('Actions.Views.Channel', () => {
|
|||
channelSelectors.getCurrentChannelId = jest.fn(() => currentChannelId);
|
||||
channelSelectors.getMyChannelMember = jest.fn(() => ({data: {member: {}}}));
|
||||
|
||||
const appChannelSelectors = require('app/selectors/channel');
|
||||
const appChannelSelectors = require('@selectors/channel');
|
||||
const getChannelReachableOriginal = appChannelSelectors.getChannelReachable;
|
||||
appChannelSelectors.getChannelReachable = jest.fn(() => true);
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ export function executeCommand(message: string, channelId: string, rootId: strin
|
|||
|
||||
const appsAreEnabled = appsEnabled(state);
|
||||
if (appsAreEnabled) {
|
||||
const parser = new AppCommandParser({dispatch, getState}, intl, args.channel_id, args.root_id);
|
||||
const parser = new AppCommandParser({dispatch, getState}, intl, args.channel_id, args.team_id, args.root_id);
|
||||
if (parser.isAppCommand(msg)) {
|
||||
const {call, errorMessage} = await parser.composeCallFromCommand(msg);
|
||||
const createErrorMessage = (errMessage: string) => {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import ChannelMention from './channel_mention';
|
|||
import DateSuggestion from './date_suggestion';
|
||||
import EmojiSuggestion from './emoji_suggestion';
|
||||
import SlashSuggestion from './slash_suggestion';
|
||||
import AppSlashSuggestion from './slash_suggestion/app_slash_suggestion';
|
||||
|
||||
export default class Autocomplete extends PureComponent {
|
||||
static propTypes = {
|
||||
|
|
@ -39,6 +40,7 @@ export default class Autocomplete extends PureComponent {
|
|||
offsetY: PropTypes.number,
|
||||
onKeyboardOffsetChanged: PropTypes.func,
|
||||
style: ViewPropTypes.style,
|
||||
appsEnabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
|
|
@ -77,6 +79,7 @@ export default class Autocomplete extends PureComponent {
|
|||
cursorPosition: props.cursorPosition,
|
||||
emojiCount: 0,
|
||||
commandCount: 0,
|
||||
appCommandCount: 0,
|
||||
dateCount: 0,
|
||||
keyboardOffset: 0,
|
||||
value: props.value,
|
||||
|
|
@ -137,6 +140,10 @@ export default class Autocomplete extends PureComponent {
|
|||
this.setState({emojiCount});
|
||||
};
|
||||
|
||||
handleAppCommandCountChange = (appCommandCount) => {
|
||||
this.setState({appCommandCount});
|
||||
}
|
||||
|
||||
handleCommandCountChange = (commandCount) => {
|
||||
this.setState({commandCount});
|
||||
};
|
||||
|
|
@ -178,8 +185,8 @@ export default class Autocomplete extends PureComponent {
|
|||
}
|
||||
|
||||
render() {
|
||||
const {atMentionCount, channelMentionCount, emojiCount, commandCount, dateCount, cursorPosition, value} = this.state;
|
||||
const {theme, isSearch, offsetY} = this.props;
|
||||
const {atMentionCount, channelMentionCount, emojiCount, commandCount, appCommandCount, dateCount, cursorPosition, value} = this.state;
|
||||
const {theme, isSearch, offsetY, appsEnabled} = this.props;
|
||||
const style = getStyleFromTheme(theme);
|
||||
const maxListHeight = this.maxListHeight();
|
||||
const wrapperStyles = [];
|
||||
|
|
@ -197,11 +204,12 @@ export default class Autocomplete extends PureComponent {
|
|||
}
|
||||
|
||||
// Hide when there are no active autocompletes
|
||||
if (atMentionCount + channelMentionCount + emojiCount + commandCount + dateCount === 0) {
|
||||
if (atMentionCount + channelMentionCount + emojiCount + commandCount + appCommandCount + dateCount === 0) {
|
||||
wrapperStyles.push(style.hidden);
|
||||
containerStyles.push(style.hidden);
|
||||
}
|
||||
|
||||
const appsTakeOver = this.state.appCommandCount > 0;
|
||||
return (
|
||||
<View
|
||||
style={wrapperStyles}
|
||||
|
|
@ -212,50 +220,62 @@ export default class Autocomplete extends PureComponent {
|
|||
ref={this.containerRef}
|
||||
style={containerStyles}
|
||||
>
|
||||
<AtMention
|
||||
{...this.props}
|
||||
cursorPosition={cursorPosition}
|
||||
maxListHeight={maxListHeight}
|
||||
onChangeText={this.onChangeText}
|
||||
onResultCountChange={this.handleAtMentionCountChange}
|
||||
value={value || ''}
|
||||
nestedScrollEnabled={this.props.nestedScrollEnabled}
|
||||
/>
|
||||
<ChannelMention
|
||||
{...this.props}
|
||||
cursorPosition={cursorPosition}
|
||||
maxListHeight={maxListHeight}
|
||||
onChangeText={this.onChangeText}
|
||||
onResultCountChange={this.handleChannelMentionCountChange}
|
||||
value={value || ''}
|
||||
nestedScrollEnabled={this.props.nestedScrollEnabled}
|
||||
/>
|
||||
<EmojiSuggestion
|
||||
{...this.props}
|
||||
cursorPosition={cursorPosition}
|
||||
maxListHeight={maxListHeight}
|
||||
onChangeText={this.onChangeText}
|
||||
onResultCountChange={this.handleEmojiCountChange}
|
||||
value={value || ''}
|
||||
nestedScrollEnabled={this.props.nestedScrollEnabled}
|
||||
/>
|
||||
<SlashSuggestion
|
||||
{...this.props}
|
||||
maxListHeight={maxListHeight}
|
||||
onChangeText={this.onChangeText}
|
||||
onResultCountChange={this.handleCommandCountChange}
|
||||
value={value || ''}
|
||||
nestedScrollEnabled={this.props.nestedScrollEnabled}
|
||||
/>
|
||||
{(this.props.isSearch && this.props.enableDateSuggestion) &&
|
||||
<DateSuggestion
|
||||
{...this.props}
|
||||
cursorPosition={cursorPosition}
|
||||
onChangeText={this.onChangeText}
|
||||
onResultCountChange={this.handleIsDateFilterChange}
|
||||
value={value || ''}
|
||||
/>
|
||||
}
|
||||
{appsEnabled && (
|
||||
<AppSlashSuggestion
|
||||
{...this.props}
|
||||
maxListHeight={maxListHeight}
|
||||
onChangeText={this.onChangeText}
|
||||
onResultCountChange={this.handleAppCommandCountChange}
|
||||
value={value || ''}
|
||||
nestedScrollEnabled={this.props.nestedScrollEnabled}
|
||||
/>
|
||||
)}
|
||||
{(!appsTakeOver || !appsEnabled) && (<>
|
||||
<AtMention
|
||||
{...this.props}
|
||||
cursorPosition={cursorPosition}
|
||||
maxListHeight={maxListHeight}
|
||||
onChangeText={this.onChangeText}
|
||||
onResultCountChange={this.handleAtMentionCountChange}
|
||||
value={value || ''}
|
||||
nestedScrollEnabled={this.props.nestedScrollEnabled}
|
||||
/>
|
||||
<ChannelMention
|
||||
{...this.props}
|
||||
cursorPosition={cursorPosition}
|
||||
maxListHeight={maxListHeight}
|
||||
onChangeText={this.onChangeText}
|
||||
onResultCountChange={this.handleChannelMentionCountChange}
|
||||
value={value || ''}
|
||||
nestedScrollEnabled={this.props.nestedScrollEnabled}
|
||||
/>
|
||||
<EmojiSuggestion
|
||||
{...this.props}
|
||||
cursorPosition={cursorPosition}
|
||||
maxListHeight={maxListHeight}
|
||||
onChangeText={this.onChangeText}
|
||||
onResultCountChange={this.handleEmojiCountChange}
|
||||
value={value || ''}
|
||||
nestedScrollEnabled={this.props.nestedScrollEnabled}
|
||||
/>
|
||||
<SlashSuggestion
|
||||
{...this.props}
|
||||
maxListHeight={maxListHeight}
|
||||
onChangeText={this.onChangeText}
|
||||
onResultCountChange={this.handleCommandCountChange}
|
||||
value={value || ''}
|
||||
nestedScrollEnabled={this.props.nestedScrollEnabled}
|
||||
/>
|
||||
{(this.props.isSearch && this.props.enableDateSuggestion) &&
|
||||
<DateSuggestion
|
||||
{...this.props}
|
||||
cursorPosition={cursorPosition}
|
||||
onChangeText={this.onChangeText}
|
||||
onResultCountChange={this.handleIsDateFilterChange}
|
||||
value={value || ''}
|
||||
/>
|
||||
}
|
||||
</>)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {Text, View} from 'react-native';
|
||||
import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
||||
|
|
@ -10,9 +9,10 @@ import CompassIcon from '@components/compass_icon';
|
|||
import {BotTag, GuestTag} from '@components/tag';
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {General} from '@mm-redux/constants';
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
icon: {
|
||||
fontSize: 18,
|
||||
|
|
@ -38,7 +38,20 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
|
|||
};
|
||||
});
|
||||
|
||||
const ChannelMentionItem = (props) => {
|
||||
type Props = {
|
||||
channelId: string;
|
||||
displayName?: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
isBot: boolean;
|
||||
isGuest: boolean;
|
||||
onPress: (name?: string) => void;
|
||||
theme: Theme;
|
||||
shared: boolean;
|
||||
testID?: string;
|
||||
};
|
||||
|
||||
const ChannelMentionItem = (props: Props) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const {
|
||||
channelId,
|
||||
|
|
@ -55,7 +68,7 @@ const ChannelMentionItem = (props) => {
|
|||
|
||||
const completeMention = () => {
|
||||
if (type === General.DM_CHANNEL || type === General.GM_CHANNEL) {
|
||||
onPress('@' + displayName.replace(/ /g, ''));
|
||||
onPress('@' + displayName?.replace(/ /g, ''));
|
||||
} else {
|
||||
onPress(name);
|
||||
}
|
||||
|
|
@ -120,15 +133,4 @@ const ChannelMentionItem = (props) => {
|
|||
return component;
|
||||
};
|
||||
|
||||
ChannelMentionItem.propTypes = {
|
||||
channelId: PropTypes.string.isRequired,
|
||||
displayName: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
isBot: PropTypes.bool.isRequired,
|
||||
isGuest: PropTypes.bool.isRequired,
|
||||
onPress: PropTypes.func.isRequired,
|
||||
theme: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default ChannelMentionItem;
|
||||
|
|
@ -7,19 +7,24 @@ import {General} from '@mm-redux/constants';
|
|||
import {getChannel} from '@mm-redux/selectors/entities/channels';
|
||||
import {getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
import {getUser} from '@mm-redux/selectors/entities/users';
|
||||
import {GlobalState} from '@mm-redux/types/store';
|
||||
import {getChannelNameForSearchAutocomplete} from '@selectors/channel';
|
||||
import {isGuest as isGuestUser} from '@utils/users';
|
||||
|
||||
import ChannelMentionItem from './channel_mention_item';
|
||||
|
||||
function mapStateToProps(state, ownProps) {
|
||||
type OwnProps = {
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
|
||||
const channel = getChannel(state, ownProps.channelId);
|
||||
let displayName = getChannelNameForSearchAutocomplete(state, ownProps.channelId);
|
||||
|
||||
let isBot = false;
|
||||
let isGuest = false;
|
||||
if (channel?.type === General.DM_CHANNEL) {
|
||||
const teammate = getUser(state, channel.teammate_id);
|
||||
const teammate = getUser(state, channel.teammate_id!);
|
||||
if (teammate) {
|
||||
displayName = teammate.username;
|
||||
isBot = teammate.is_bot || false;
|
||||
|
|
@ -5,6 +5,7 @@ import {connect} from 'react-redux';
|
|||
|
||||
import {getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
import {getDimensions} from '@selectors/device';
|
||||
import {appsEnabled} from '@utils/apps';
|
||||
|
||||
import Autocomplete from './autocomplete';
|
||||
|
||||
|
|
@ -13,6 +14,7 @@ function mapStateToProps(state) {
|
|||
return {
|
||||
deviceHeight,
|
||||
theme: getTheme(state),
|
||||
appsEnabled: appsEnabled(state),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ describe('AppCommandParser', () => {
|
|||
let parser: AppCommandParser;
|
||||
beforeEach(async () => {
|
||||
const store = await makeStore(testBindings);
|
||||
parser = new AppCommandParser(store as any, intl, 'current_channel_id', 'root_id');
|
||||
parser = new AppCommandParser(store as any, intl, 'current_channel_id', 'team_id', 'root_id');
|
||||
});
|
||||
|
||||
type Variant = {
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@ import {
|
|||
AppCallRequest,
|
||||
AppBinding,
|
||||
AppField,
|
||||
AppSelectOption,
|
||||
AppCallResponse,
|
||||
DoAppCallResult,
|
||||
AppLookupResponse,
|
||||
AppContext,
|
||||
AppForm,
|
||||
AppCallValues,
|
||||
AppSelectOption,
|
||||
AutocompleteSuggestion,
|
||||
AutocompleteStaticSelect,
|
||||
Channel,
|
||||
|
|
@ -22,51 +23,57 @@ import {
|
|||
AppCallResponseTypes,
|
||||
AppCallTypes,
|
||||
AppFieldTypes,
|
||||
getAppsBindings,
|
||||
makeAppBindingsSelector,
|
||||
selectChannel,
|
||||
getChannel,
|
||||
getCurrentTeamId,
|
||||
doAppCall,
|
||||
getStore,
|
||||
EXECUTE_CURRENT_COMMAND_ITEM_ID,
|
||||
COMMAND_SUGGESTION_ERROR,
|
||||
getExecuteSuggestion,
|
||||
displayError,
|
||||
keyMirror,
|
||||
createCallRequest,
|
||||
selectUserByUsername,
|
||||
getUserByUsername,
|
||||
selectUser,
|
||||
getUser,
|
||||
getChannelByNameAndTeamName,
|
||||
getCurrentTeam,
|
||||
selectChannelByName,
|
||||
errorMessage as parserErrorMessage,
|
||||
selectUser,
|
||||
getUser,
|
||||
getChannel,
|
||||
autocompleteUsersInChannel,
|
||||
autocompleteChannels,
|
||||
ExtendedAutocompleteSuggestion,
|
||||
getChannelSuggestions,
|
||||
getUserSuggestions,
|
||||
inTextMentionSuggestions,
|
||||
} from './app_command_parser_dependencies';
|
||||
|
||||
export type Store = {
|
||||
export interface Store {
|
||||
dispatch: DispatchFunc;
|
||||
getState: () => GlobalState;
|
||||
}
|
||||
|
||||
export const ParseState = keyMirror({
|
||||
Start: null,
|
||||
Command: null,
|
||||
EndCommand: null,
|
||||
CommandSeparator: null,
|
||||
StartParameter: null,
|
||||
ParameterSeparator: null,
|
||||
Flag1: null,
|
||||
Flag: null,
|
||||
FlagValueSeparator: null,
|
||||
StartValue: null,
|
||||
NonspaceValue: null,
|
||||
QuotedValue: null,
|
||||
TickValue: null,
|
||||
EndValue: null,
|
||||
EndQuotedValue: null,
|
||||
EndTickedValue: null,
|
||||
Error: null,
|
||||
});
|
||||
export enum ParseState {
|
||||
Start = 'Start',
|
||||
Command = 'Command',
|
||||
EndCommand = 'EndCommand',
|
||||
CommandSeparator = 'CommandSeparator',
|
||||
StartParameter = 'StartParameter',
|
||||
ParameterSeparator = 'ParameterSeparator',
|
||||
Flag1 = 'Flag1',
|
||||
Flag = 'Flag',
|
||||
FlagValueSeparator = 'FlagValueSeparator',
|
||||
StartValue = 'StartValue',
|
||||
NonspaceValue = 'NonspaceValue',
|
||||
QuotedValue = 'QuotedValue',
|
||||
TickValue = 'TickValue',
|
||||
EndValue = 'EndValue',
|
||||
EndQuotedValue = 'EndQuotedValue',
|
||||
EndTickedValue = 'EndTickedValue',
|
||||
Error = 'Error',
|
||||
}
|
||||
|
||||
interface FormsCache {
|
||||
getForm: (location: string, binding: AppBinding) => Promise<{form?: AppForm; error?: string} | undefined>;
|
||||
|
|
@ -76,8 +83,10 @@ interface Intl {
|
|||
formatMessage(config: {id: string; defaultMessage: string}, values?: {[name: string]: any}): string;
|
||||
}
|
||||
|
||||
const getCommandBindings = makeAppBindingsSelector(AppBindingLocations.COMMAND);
|
||||
|
||||
export class ParsedCommand {
|
||||
state: string = ParseState.Start;
|
||||
state = ParseState.Start;
|
||||
command: string;
|
||||
i = 0;
|
||||
incomplete = '';
|
||||
|
|
@ -98,14 +107,14 @@ export class ParsedCommand {
|
|||
this.intl = intl;
|
||||
}
|
||||
|
||||
asError = (message: string): ParsedCommand => {
|
||||
private asError = (message: string): ParsedCommand => {
|
||||
this.state = ParseState.Error;
|
||||
this.error = message;
|
||||
return this;
|
||||
};
|
||||
|
||||
// matchBinding finds the closest matching command binding.
|
||||
matchBinding = async (commandBindings: AppBinding[], autocompleteMode = false): Promise<ParsedCommand> => {
|
||||
public matchBinding = async (commandBindings: AppBinding[], autocompleteMode = false): Promise<ParsedCommand> => {
|
||||
if (commandBindings.length === 0) {
|
||||
return this.asError(this.intl.formatMessage({
|
||||
id: 'apps.error.parser.no_bindings',
|
||||
|
|
@ -229,7 +238,7 @@ export class ParsedCommand {
|
|||
}
|
||||
|
||||
// parseForm parses the rest of the command using the previously matched form.
|
||||
parseForm = (autocompleteMode = false): ParsedCommand => {
|
||||
public parseForm = (autocompleteMode = false): ParsedCommand => {
|
||||
if (this.state === ParseState.Error || !this.form) {
|
||||
return this;
|
||||
}
|
||||
|
|
@ -545,15 +554,17 @@ export class ParsedCommand {
|
|||
export class AppCommandParser {
|
||||
private store: Store;
|
||||
private channelID: string;
|
||||
private teamID: string;
|
||||
private rootPostID?: string;
|
||||
private intl: Intl;
|
||||
|
||||
forms: {[location: string]: AppForm} = {};
|
||||
|
||||
constructor(store: Store|null, intl: Intl, channelID: string, rootPostID = '') {
|
||||
constructor(store: Store|null, intl: Intl, channelID: string, teamID = '', rootPostID = '') {
|
||||
this.store = store || getStore() as Store;
|
||||
this.channelID = channelID;
|
||||
this.rootPostID = rootPostID;
|
||||
this.teamID = teamID;
|
||||
this.intl = intl;
|
||||
}
|
||||
|
||||
|
|
@ -681,9 +692,9 @@ export class AppCommandParser {
|
|||
}
|
||||
|
||||
// getSuggestions returns suggestions for subcommands and/or form arguments
|
||||
public getSuggestions = async (pretext: string): Promise<AutocompleteSuggestion[]> => {
|
||||
public getSuggestions = async (pretext: string): Promise<ExtendedAutocompleteSuggestion[]> => {
|
||||
let parsed = new ParsedCommand(pretext, this, this.intl);
|
||||
let suggestions: AutocompleteSuggestion[] = [];
|
||||
let suggestions: ExtendedAutocompleteSuggestion[] = [];
|
||||
|
||||
const commandBindings = this.getCommandBindings();
|
||||
if (!commandBindings) {
|
||||
|
|
@ -740,7 +751,7 @@ export class AppCommandParser {
|
|||
id: 'apps.suggestion.no_suggestion',
|
||||
defaultMessage: 'No matching suggestions.',
|
||||
}),
|
||||
IconData: 'error',
|
||||
IconData: COMMAND_SUGGESTION_ERROR,
|
||||
Description: '',
|
||||
}];
|
||||
}
|
||||
|
|
@ -753,13 +764,13 @@ export class AppCommandParser {
|
|||
id: 'apps.suggestion.errors.parser_error',
|
||||
defaultMessage: 'Parsing error',
|
||||
}),
|
||||
IconData: 'error',
|
||||
IconData: COMMAND_SUGGESTION_ERROR,
|
||||
Description: parsed.error,
|
||||
}];
|
||||
}
|
||||
|
||||
// composeCallFromParsed creates the form submission call
|
||||
composeCallFromParsed = async (parsed: ParsedCommand): Promise<{call: AppCallRequest | null; errorMessage?: string}> => {
|
||||
private composeCallFromParsed = async (parsed: ParsedCommand): Promise<{call: AppCallRequest | null; errorMessage?: string}> => {
|
||||
if (!parsed.binding) {
|
||||
return {call: null,
|
||||
errorMessage: this.intl.formatMessage({
|
||||
|
|
@ -788,7 +799,7 @@ export class AppCommandParser {
|
|||
return {call: createCallRequest(call, context, {}, values, parsed.command)};
|
||||
}
|
||||
|
||||
expandOptions = async (parsed: ParsedCommand, values: AppCallValues): Promise<{errorMessage?: string}> => {
|
||||
private expandOptions = async (parsed: ParsedCommand, values: AppCallValues): Promise<{errorMessage?: string}> => {
|
||||
if (!parsed.form?.fields) {
|
||||
return {};
|
||||
}
|
||||
|
|
@ -878,7 +889,7 @@ export class AppCommandParser {
|
|||
}
|
||||
|
||||
// decorateSuggestionComplete applies the necessary modifications for a suggestion to be processed
|
||||
decorateSuggestionComplete = (parsed: ParsedCommand, choice: AutocompleteSuggestion): AutocompleteSuggestion => {
|
||||
private decorateSuggestionComplete = (parsed: ParsedCommand, choice: AutocompleteSuggestion): AutocompleteSuggestion => {
|
||||
if (choice.Complete && choice.Complete.endsWith(EXECUTE_CURRENT_COMMAND_ITEM_ID)) {
|
||||
return choice as AutocompleteSuggestion;
|
||||
}
|
||||
|
|
@ -900,27 +911,30 @@ export class AppCommandParser {
|
|||
|
||||
// getCommandBindings returns the commands in the redux store.
|
||||
// They are grouped by app id since each app has one base command
|
||||
getCommandBindings = (): AppBinding[] => {
|
||||
const bindings = getAppsBindings(this.store.getState(), AppBindingLocations.COMMAND);
|
||||
private getCommandBindings = (): AppBinding[] => {
|
||||
const bindings = getCommandBindings(this.store.getState());
|
||||
return bindings;
|
||||
}
|
||||
|
||||
// getChannel gets the channel in which the user is typing the command
|
||||
getChannel = (): Channel | null => {
|
||||
private getChannel = (): Channel | null => {
|
||||
const state = this.store.getState();
|
||||
return selectChannel(state, this.channelID);
|
||||
}
|
||||
|
||||
setChannelContext = (channelID: string, rootPostID?: string) => {
|
||||
if (this.channelID !== channelID || this.rootPostID !== rootPostID) {
|
||||
public setChannelContext = (channelID: string, teamID = '', rootPostID?: string) => {
|
||||
if (this.channelID !== channelID || this.rootPostID !== rootPostID || this.teamID !== teamID) {
|
||||
this.forms = {};
|
||||
}
|
||||
this.channelID = channelID;
|
||||
this.rootPostID = rootPostID;
|
||||
this.teamID = teamID;
|
||||
}
|
||||
|
||||
// isAppCommand determines if subcommand/form suggestions need to be returned
|
||||
isAppCommand = (pretext: string): boolean => {
|
||||
// isAppCommand determines if subcommand/form suggestions need to be returned.
|
||||
// When this returns true, the caller knows that the parser should handle all suggestions for the current command string.
|
||||
// When it returns false, the caller should call getSuggestionsBase() to check if there are any base commands that match the command string.
|
||||
public isAppCommand = (pretext: string): boolean => {
|
||||
const command = pretext.toLowerCase();
|
||||
for (const binding of this.getCommandBindings()) {
|
||||
let base = binding.label;
|
||||
|
|
@ -940,7 +954,7 @@ export class AppCommandParser {
|
|||
}
|
||||
|
||||
// getAppContext collects post/channel/team info for performing calls
|
||||
getAppContext = (appID: string): AppContext => {
|
||||
private getAppContext = (appID: string): AppContext => {
|
||||
const context: AppContext = {
|
||||
app_id: appID,
|
||||
location: AppBindingLocations.COMMAND,
|
||||
|
|
@ -953,13 +967,13 @@ export class AppCommandParser {
|
|||
}
|
||||
|
||||
context.channel_id = channel.id;
|
||||
context.team_id = channel.team_id || getCurrentTeamId(this.store.getState());
|
||||
context.team_id = this.teamID || channel.team_id || getCurrentTeamId(this.store.getState());
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
// fetchForm unconditionaly retrieves the form for the given binding (subcommand)
|
||||
fetchForm = async (binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => {
|
||||
private fetchForm = async (binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => {
|
||||
if (!binding.call) {
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -969,16 +983,16 @@ export class AppCommandParser {
|
|||
this.getAppContext(binding.app_id),
|
||||
);
|
||||
|
||||
const res = await this.store.dispatch(doAppCall(payload, AppCallTypes.FORM, this.intl));
|
||||
const res = await this.store.dispatch(doAppCall(payload, AppCallTypes.FORM, this.intl)) as DoAppCallResult;
|
||||
if (res.error) {
|
||||
const errorResponse = res.error as AppCallResponse;
|
||||
const errorResponse = res.error;
|
||||
return {error: errorResponse.error || this.intl.formatMessage({
|
||||
id: 'apps.error.unknown',
|
||||
defaultMessage: 'Unknown error.',
|
||||
})};
|
||||
}
|
||||
|
||||
const callResponse = res.data as AppCallResponse;
|
||||
const callResponse = res.data!;
|
||||
switch (callResponse.type) {
|
||||
case AppCallResponseTypes.FORM:
|
||||
break;
|
||||
|
|
@ -1002,7 +1016,7 @@ export class AppCommandParser {
|
|||
return {form: callResponse.form};
|
||||
}
|
||||
|
||||
getForm = async (location: string, binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => {
|
||||
public getForm = async (location: string, binding: AppBinding): Promise<{form?: AppForm; error?: string} | undefined> => {
|
||||
const rootID = this.rootPostID || '';
|
||||
const key = `${this.channelID}-${rootID}-${location}`;
|
||||
const form = this.forms[key];
|
||||
|
|
@ -1019,17 +1033,16 @@ export class AppCommandParser {
|
|||
}
|
||||
|
||||
// displayError shows an error that was caught by the parser
|
||||
displayError = (err: any): void => {
|
||||
private displayError = (err: any): void => {
|
||||
let errStr = err as string;
|
||||
if (err.message) {
|
||||
errStr = err.message;
|
||||
}
|
||||
|
||||
displayError(this.intl, errStr);
|
||||
}
|
||||
|
||||
// getSuggestionsForSubCommands returns suggestions for a subcommand's name
|
||||
getCommandSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => {
|
||||
private getCommandSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => {
|
||||
if (!parsed.binding?.bindings?.length) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -1052,7 +1065,7 @@ export class AppCommandParser {
|
|||
}
|
||||
|
||||
// getParameterSuggestions computes suggestions for positional argument values, flag names, and flag argument values
|
||||
getParameterSuggestions = async (parsed: ParsedCommand): Promise<AutocompleteSuggestion[]> => {
|
||||
private getParameterSuggestions = async (parsed: ParsedCommand): Promise<ExtendedAutocompleteSuggestion[]> => {
|
||||
switch (parsed.state) {
|
||||
case ParseState.StartParameter: {
|
||||
// see if there's a matching positional field
|
||||
|
|
@ -1082,7 +1095,7 @@ export class AppCommandParser {
|
|||
}
|
||||
|
||||
// getMissingFields collects the required fields that were not supplied in a submission
|
||||
getMissingFields = (parsed: ParsedCommand): AppField[] => {
|
||||
private getMissingFields = (parsed: ParsedCommand): AppField[] => {
|
||||
const form = parsed.form;
|
||||
if (!form) {
|
||||
return [];
|
||||
|
|
@ -1102,7 +1115,7 @@ export class AppCommandParser {
|
|||
}
|
||||
|
||||
// getFlagNameSuggestions returns suggestions for flag names
|
||||
getFlagNameSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => {
|
||||
private getFlagNameSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => {
|
||||
if (!parsed.form || !parsed.form.fields || !parsed.form.fields.length) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -1134,7 +1147,7 @@ export class AppCommandParser {
|
|||
}
|
||||
|
||||
// getSuggestionsForField gets suggestions for a positional or flag field value
|
||||
getValueSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise<AutocompleteSuggestion[]> => {
|
||||
private getValueSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise<ExtendedAutocompleteSuggestion[]> => {
|
||||
if (!parsed || !parsed.field) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -1142,9 +1155,9 @@ export class AppCommandParser {
|
|||
|
||||
switch (f.type) {
|
||||
case AppFieldTypes.USER:
|
||||
return this.getUserSuggestions(parsed);
|
||||
return this.getUserFieldSuggestions(parsed);
|
||||
case AppFieldTypes.CHANNEL:
|
||||
return this.getChannelSuggestions(parsed);
|
||||
return this.getChannelFieldSuggestions(parsed);
|
||||
case AppFieldTypes.BOOL:
|
||||
return this.getBooleanSuggestions(parsed);
|
||||
case AppFieldTypes.DYNAMIC_SELECT:
|
||||
|
|
@ -1153,14 +1166,21 @@ export class AppCommandParser {
|
|||
return this.getStaticSelectSuggestions(parsed, delimiter);
|
||||
}
|
||||
|
||||
const mentionSuggestions = await inTextMentionSuggestions(parsed.incomplete, this.store, this.channelID, this.teamID, delimiter);
|
||||
if (mentionSuggestions) {
|
||||
return mentionSuggestions;
|
||||
}
|
||||
|
||||
// Handle text values
|
||||
let complete = parsed.incomplete;
|
||||
if (complete && delimiter) {
|
||||
complete = delimiter + complete + delimiter;
|
||||
}
|
||||
|
||||
const fieldName = parsed.field.modal_label || parsed.field.label || parsed.field.name;
|
||||
return [{
|
||||
Complete: complete,
|
||||
Suggestion: `${parsed.field.label || parsed.field.name}: ${delimiter || '"'}${parsed.incomplete}${delimiter || '"'}`,
|
||||
Suggestion: `${fieldName}: ${delimiter || '"'}${parsed.incomplete}${delimiter || '"'}`,
|
||||
Description: f.description || '',
|
||||
Hint: '',
|
||||
IconData: parsed.binding?.icon || '',
|
||||
|
|
@ -1168,7 +1188,7 @@ export class AppCommandParser {
|
|||
}
|
||||
|
||||
// getStaticSelectSuggestions returns suggestions specified in the field's options property
|
||||
getStaticSelectSuggestions = (parsed: ParsedCommand, delimiter?: string): AutocompleteSuggestion[] => {
|
||||
private getStaticSelectSuggestions = (parsed: ParsedCommand, delimiter?: string): AutocompleteSuggestion[] => {
|
||||
const f = parsed.field as AutocompleteStaticSelect;
|
||||
const opts = f.options?.filter((opt) => opt.label.toLowerCase().startsWith(parsed.incomplete.toLowerCase()));
|
||||
if (!opts?.length) {
|
||||
|
|
@ -1180,7 +1200,7 @@ export class AppCommandParser {
|
|||
defaultMessage: 'No matching options.',
|
||||
}),
|
||||
Description: '',
|
||||
IconData: 'error',
|
||||
IconData: COMMAND_SUGGESTION_ERROR,
|
||||
}];
|
||||
}
|
||||
return opts.map((opt) => {
|
||||
|
|
@ -1201,7 +1221,7 @@ export class AppCommandParser {
|
|||
}
|
||||
|
||||
// getDynamicSelectSuggestions fetches and returns suggestions from the server
|
||||
getDynamicSelectSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise<AutocompleteSuggestion[]> => {
|
||||
private getDynamicSelectSuggestions = async (parsed: ParsedCommand, delimiter?: string): Promise<AutocompleteSuggestion[]> => {
|
||||
const f = parsed.field;
|
||||
if (!f) {
|
||||
// Should never happen
|
||||
|
|
@ -1223,17 +1243,17 @@ export class AppCommandParser {
|
|||
call.selected_field = f.name;
|
||||
call.query = parsed.incomplete;
|
||||
|
||||
type ResponseType = {items: AppSelectOption[]};
|
||||
const res = await this.store.dispatch(doAppCall<ResponseType>(call, AppCallTypes.LOOKUP, this.intl));
|
||||
const res = await this.store.dispatch(doAppCall(call, AppCallTypes.LOOKUP, this.intl)) as DoAppCallResult<AppLookupResponse>;
|
||||
|
||||
if (res.error) {
|
||||
const errorResponse = res.error as AppCallResponse;
|
||||
const errorResponse = res.error;
|
||||
return this.makeDynamicSelectSuggestionError(errorResponse.error || this.intl.formatMessage({
|
||||
id: 'apps.error.unknown',
|
||||
defaultMessage: 'Unknown error.',
|
||||
}));
|
||||
}
|
||||
|
||||
const callResponse = res.data as AppCallResponse<ResponseType>;
|
||||
const callResponse = res.data!;
|
||||
switch (callResponse.type) {
|
||||
case AppCallResponseTypes.OK:
|
||||
break;
|
||||
|
|
@ -1288,7 +1308,7 @@ export class AppCommandParser {
|
|||
});
|
||||
}
|
||||
|
||||
makeDynamicSelectSuggestionError = (message: string): AutocompleteSuggestion[] => {
|
||||
private makeDynamicSelectSuggestionError = (message: string): AutocompleteSuggestion[] => {
|
||||
const errMsg = this.intl.formatMessage({
|
||||
id: 'apps.error',
|
||||
defaultMessage: 'Error: {error}',
|
||||
|
|
@ -1302,43 +1322,31 @@ export class AppCommandParser {
|
|||
defaultMessage: 'Dynamic select error',
|
||||
}),
|
||||
Hint: '',
|
||||
IconData: 'error',
|
||||
IconData: COMMAND_SUGGESTION_ERROR,
|
||||
Description: errMsg,
|
||||
}];
|
||||
}
|
||||
|
||||
// getUserSuggestions returns a suggestion with `@` if the user has not started typing
|
||||
getUserSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => {
|
||||
if (parsed.incomplete.trim().length === 0) {
|
||||
return [{
|
||||
Complete: '',
|
||||
Suggestion: '',
|
||||
Description: parsed.field?.description || '',
|
||||
Hint: parsed.field?.hint || '@username',
|
||||
IconData: parsed.binding?.icon || '',
|
||||
}];
|
||||
private getUserFieldSuggestions = async (parsed: ParsedCommand): Promise<AutocompleteSuggestion[]> => {
|
||||
let input = parsed.incomplete.trim();
|
||||
if (input[0] === '@') {
|
||||
input = input.substring(1);
|
||||
}
|
||||
|
||||
return [];
|
||||
const {data} = await this.store.dispatch(autocompleteUsersInChannel(input, this.channelID));
|
||||
return getUserSuggestions(data);
|
||||
}
|
||||
|
||||
// getChannelSuggestions returns a suggestion with `~` if the user has not started typing
|
||||
getChannelSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => {
|
||||
if (parsed.incomplete.trim().length === 0) {
|
||||
return [{
|
||||
Complete: '',
|
||||
Suggestion: '',
|
||||
Description: parsed.field?.description || '',
|
||||
Hint: parsed.field?.hint || '~channelname',
|
||||
IconData: parsed.binding?.icon || '',
|
||||
}];
|
||||
private getChannelFieldSuggestions = async (parsed: ParsedCommand): Promise<AutocompleteSuggestion[]> => {
|
||||
let input = parsed.incomplete.trim();
|
||||
if (input[0] === '~') {
|
||||
input = input.substring(1);
|
||||
}
|
||||
|
||||
return [];
|
||||
const {data} = await this.store.dispatch(autocompleteChannels(this.teamID, input));
|
||||
return getChannelSuggestions(data);
|
||||
}
|
||||
|
||||
// getBooleanSuggestions returns true/false suggestions
|
||||
getBooleanSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => {
|
||||
private getBooleanSuggestions = (parsed: ParsedCommand): AutocompleteSuggestion[] => {
|
||||
const suggestions: AutocompleteSuggestion[] = [];
|
||||
|
||||
if ('true'.startsWith(parsed.incomplete)) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@
|
|||
import {intlShape} from 'react-intl';
|
||||
import {Alert} from 'react-native';
|
||||
|
||||
import keyMirror from '@mm-redux/utils/key_mirror';
|
||||
import {getUserByUsername, getUser, autocompleteUsers} from '@mm-redux/actions/users';
|
||||
import {getCurrentTeamId, getCurrentTeam} from '@mm-redux/selectors/entities/teams';
|
||||
import {
|
||||
ActionFunc,
|
||||
DispatchFunc,
|
||||
} from '@mm-redux/types/actions';
|
||||
import Store from '@store/store';
|
||||
|
||||
import type {ParsedCommand} from './app_command_parser';
|
||||
|
|
@ -24,8 +29,14 @@ export type {
|
|||
AutocompleteStaticSelect,
|
||||
AutocompleteUserSelect,
|
||||
AutocompleteChannelSelect,
|
||||
AppLookupResponse,
|
||||
UserAutocomplete,
|
||||
} from '@mm-redux/types/apps';
|
||||
|
||||
export type {
|
||||
DoAppCallResult,
|
||||
} from 'types/actions/apps';
|
||||
|
||||
export type {AutocompleteSuggestion};
|
||||
|
||||
export type {
|
||||
|
|
@ -38,33 +49,62 @@ export type {
|
|||
|
||||
export type {
|
||||
DispatchFunc,
|
||||
} from '@mm-redux/types/actions';
|
||||
};
|
||||
|
||||
export type {
|
||||
UserProfile,
|
||||
} from '@mm-redux/types/users';
|
||||
|
||||
export {
|
||||
AppBindingLocations,
|
||||
AppCallTypes,
|
||||
AppFieldTypes,
|
||||
AppCallResponseTypes,
|
||||
COMMAND_SUGGESTION_ERROR,
|
||||
COMMAND_SUGGESTION_CHANNEL,
|
||||
COMMAND_SUGGESTION_USER,
|
||||
} from '@mm-redux/constants/apps';
|
||||
|
||||
export {getAppsBindings} from '@mm-redux/selectors/entities/apps';
|
||||
export {makeAppBindingsSelector} from '@mm-redux/selectors/entities/apps';
|
||||
|
||||
export {getPost} from '@mm-redux/selectors/entities/posts';
|
||||
export {getChannel as selectChannel, getCurrentChannel, getChannelByName as selectChannelByName} from '@mm-redux/selectors/entities/channels';
|
||||
export {getCurrentTeamId, getCurrentTeam} from '@mm-redux/selectors/entities/teams';
|
||||
|
||||
export {
|
||||
getCurrentTeamId,
|
||||
getCurrentTeam,
|
||||
};
|
||||
|
||||
export {getUserByUsername as selectUserByUsername, getUser as selectUser} from '@mm-redux/selectors/entities/users';
|
||||
|
||||
export {getUserByUsername, getUser} from '@mm-redux/actions/users';
|
||||
export {getChannelByNameAndTeamName, getChannel} from '@mm-redux/actions/channels';
|
||||
export {
|
||||
getUserByUsername,
|
||||
getUser,
|
||||
autocompleteUsers,
|
||||
};
|
||||
|
||||
export {getChannelByNameAndTeamName, getChannel, autocompleteChannels} from '@mm-redux/actions/channels';
|
||||
|
||||
export {doAppCall} from '@actions/apps';
|
||||
export {createCallRequest} from '@utils/apps';
|
||||
|
||||
export const getStore = () => Store.redux;
|
||||
|
||||
export {keyMirror};
|
||||
export const autocompleteUsersInChannel = (prefix: string, channelID: string): ActionFunc => {
|
||||
return async (dispatch, getState) => {
|
||||
const state = getState();
|
||||
const currentTeamID = getCurrentTeamId(state);
|
||||
return dispatch(autocompleteUsers(prefix, currentTeamID, channelID));
|
||||
};
|
||||
};
|
||||
|
||||
export const EXECUTE_CURRENT_COMMAND_ITEM_ID = '_execute_current_command';
|
||||
|
||||
export type ExtendedAutocompleteSuggestion = AutocompleteSuggestion & {
|
||||
type?: string;
|
||||
item?: string;
|
||||
}
|
||||
|
||||
export const getExecuteSuggestion = (_: ParsedCommand): AutocompleteSuggestion | null => { // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
return null;
|
||||
};
|
||||
|
|
@ -85,3 +125,9 @@ export const errorMessage = (intl: typeof intlShape, error: string, _command: st
|
|||
error,
|
||||
});
|
||||
};
|
||||
|
||||
export {
|
||||
getChannelSuggestions,
|
||||
getUserSuggestions,
|
||||
inTextMentionSuggestions,
|
||||
} from '@utils/mentions';
|
||||
|
|
|
|||
|
|
@ -89,7 +89,10 @@ export const reduxTestState = {
|
|||
general: {
|
||||
license: {IsLicensed: 'false'},
|
||||
serverVersion: '5.25.0',
|
||||
config: {PostEditTimeLimit: -1},
|
||||
config: {
|
||||
PostEditTimeLimit: -1,
|
||||
FeatureFlagAppsEnabled: 'true',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`components/autocomplete/app_slash_suggestion should match snapshot 1`] = `
|
||||
<FlatList
|
||||
data={
|
||||
Array [
|
||||
Object {
|
||||
"Complete": "thetrigger",
|
||||
"Description": "The Description",
|
||||
"Hint": "The Hint",
|
||||
"IconData": "iconurl.com",
|
||||
"Suggestion": "/thetrigger",
|
||||
},
|
||||
]
|
||||
}
|
||||
disableVirtualization={false}
|
||||
horizontal={false}
|
||||
initialNumToRender={10}
|
||||
keyExtractor={[Function]}
|
||||
keyboardShouldPersistTaps="always"
|
||||
maxToRenderPerBatch={10}
|
||||
nestedScrollEnabled={false}
|
||||
numColumns={1}
|
||||
onEndReachedThreshold={2}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={[Function]}
|
||||
scrollEventThrottle={50}
|
||||
style={
|
||||
Array [
|
||||
Object {
|
||||
"backgroundColor": "#ffffff",
|
||||
"borderRadius": 4,
|
||||
"flex": 1,
|
||||
"paddingTop": 8,
|
||||
},
|
||||
Object {
|
||||
"maxHeight": 50,
|
||||
},
|
||||
]
|
||||
}
|
||||
testID="app_slash_suggestion.list"
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={21}
|
||||
/>
|
||||
`;
|
||||
|
|
@ -0,0 +1,147 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {shallow} from 'enzyme';
|
||||
import React from 'react';
|
||||
import {intl} from 'test/intl-test-helper';
|
||||
|
||||
import Preferences from '@mm-redux/constants/preferences';
|
||||
import {AutocompleteSuggestion} from '@mm-redux/types/integrations';
|
||||
import Store from '@store/store';
|
||||
|
||||
import {
|
||||
reduxTestState,
|
||||
testBindings,
|
||||
} from '../app_command_parser/tests/app_command_parser_test_data';
|
||||
import {
|
||||
thunk,
|
||||
configureStore,
|
||||
Client4,
|
||||
AppBinding,
|
||||
} from '../app_command_parser/tests/app_command_parser_test_dependencies';
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
|
||||
const makeStore = async (bindings: AppBinding[]) => {
|
||||
const initialState = {
|
||||
...reduxTestState,
|
||||
entities: {
|
||||
...reduxTestState.entities,
|
||||
apps: {bindings},
|
||||
},
|
||||
} as any;
|
||||
const testStore = await mockStore(initialState);
|
||||
|
||||
return testStore;
|
||||
};
|
||||
|
||||
import SlashSuggestion, {Props} from './app_slash_suggestion';
|
||||
|
||||
describe('components/autocomplete/app_slash_suggestion', () => {
|
||||
const baseProps: Props = {
|
||||
currentTeamId: '',
|
||||
isSearch: false,
|
||||
maxListHeight: 50,
|
||||
theme: Preferences.THEMES.default,
|
||||
onChangeText: jest.fn(),
|
||||
onResultCountChange: jest.fn(),
|
||||
value: '',
|
||||
nestedScrollEnabled: false,
|
||||
rootId: '',
|
||||
channelId: 'thechannel',
|
||||
appsEnabled: true,
|
||||
};
|
||||
|
||||
const f = Client4.getServerVersion;
|
||||
|
||||
beforeAll(async () => {
|
||||
Client4.getServerVersion = jest.fn().mockReturnValue('5.30.0');
|
||||
|
||||
const store = await makeStore(testBindings);
|
||||
Store.redux = store;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Client4.getServerVersion = f;
|
||||
});
|
||||
|
||||
test('should match snapshot', () => {
|
||||
const props: Props = {
|
||||
...baseProps,
|
||||
};
|
||||
|
||||
const wrapper = shallow(
|
||||
<SlashSuggestion {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
|
||||
const dataSource: AutocompleteSuggestion[] = [
|
||||
{
|
||||
Complete: 'thetrigger',
|
||||
Description: 'The Description',
|
||||
Hint: 'The Hint',
|
||||
IconData: 'iconurl.com',
|
||||
Suggestion: '/thetrigger',
|
||||
},
|
||||
];
|
||||
wrapper.setState({active: true, dataSource, lastCommandRequest: 1234});
|
||||
|
||||
expect(wrapper.getElement()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('should not show commands from app base commands', async () => {
|
||||
const props: Props = {
|
||||
...baseProps,
|
||||
};
|
||||
|
||||
const wrapper = shallow<SlashSuggestion>(
|
||||
<SlashSuggestion {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
wrapper.setProps({value: '/ji'});
|
||||
|
||||
expect(wrapper.state('dataSource')).toEqual([]);
|
||||
});
|
||||
|
||||
test('should show commands from app sub commands', async (done) => {
|
||||
const props: Props = {
|
||||
...baseProps,
|
||||
};
|
||||
|
||||
const wrapper = shallow<SlashSuggestion>(
|
||||
<SlashSuggestion {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
wrapper.setProps({value: '/jira i'});
|
||||
|
||||
const expected: AutocompleteSuggestion[] = [
|
||||
{
|
||||
Complete: 'jira issue',
|
||||
Description: 'Interact with Jira issues',
|
||||
Hint: 'Issue hint',
|
||||
IconData: 'Issue icon',
|
||||
Suggestion: 'issue',
|
||||
},
|
||||
];
|
||||
|
||||
setTimeout(() => {
|
||||
expect(wrapper.state('dataSource')).toEqual(expected);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
test('should avoid using app commands when apps are disabled', async () => {
|
||||
const props: Props = {
|
||||
...baseProps,
|
||||
appsEnabled: false,
|
||||
};
|
||||
|
||||
const wrapper = shallow<SlashSuggestion>(
|
||||
<SlashSuggestion {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
wrapper.setProps({value: '/jira i'});
|
||||
|
||||
expect(wrapper.state('dataSource')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,238 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {PureComponent} from 'react';
|
||||
import {intlShape} from 'react-intl';
|
||||
import {
|
||||
FlatList,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
|
||||
import {Client4} from '@client/rest';
|
||||
import AtMentionItem from '@components/autocomplete/at_mention_item';
|
||||
import ChannelMentionItem from '@components/autocomplete/channel_mention_item';
|
||||
import {analytics} from '@init/analytics';
|
||||
import {AutocompleteSuggestion} from '@mm-redux/types/integrations';
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
|
||||
import {makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
import {AppCommandParser} from '../app_command_parser/app_command_parser';
|
||||
import {COMMAND_SUGGESTION_CHANNEL, COMMAND_SUGGESTION_USER, ExtendedAutocompleteSuggestion} from '../app_command_parser/app_command_parser_dependencies';
|
||||
import SlashSuggestionItem from '../slash_suggestion_item';
|
||||
|
||||
export type Props = {
|
||||
currentTeamId: string;
|
||||
isSearch?: boolean;
|
||||
maxListHeight?: number;
|
||||
theme: Theme;
|
||||
onChangeText: (text: string) => void;
|
||||
onResultCountChange: (count: number) => void;
|
||||
value: string;
|
||||
nestedScrollEnabled?: boolean;
|
||||
rootId?: string;
|
||||
channelId: string;
|
||||
appsEnabled: boolean;
|
||||
};
|
||||
|
||||
type State = {
|
||||
active: boolean;
|
||||
dataSource: AutocompleteSuggestion[];
|
||||
}
|
||||
|
||||
export default class AppSlashSuggestion extends PureComponent<Props, State> {
|
||||
static defaultProps = {
|
||||
defaultChannel: {},
|
||||
value: '',
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
appCommandParser: AppCommandParser;
|
||||
|
||||
state = {
|
||||
active: false,
|
||||
dataSource: [],
|
||||
};
|
||||
|
||||
constructor(props: Props, context: any) {
|
||||
super(props);
|
||||
this.appCommandParser = new AppCommandParser(null, context.intl, props.channelId, props.currentTeamId, props.rootId);
|
||||
}
|
||||
|
||||
setActive(active: boolean) {
|
||||
this.setState({active});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (!this.props.appsEnabled) {
|
||||
this.setActive(false);
|
||||
this.props.onResultCountChange(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
(this.props.value === prevProps.value) ||
|
||||
this.props.isSearch || this.props.value.startsWith('//') || !this.props.channelId
|
||||
) {
|
||||
this.props.onResultCountChange(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
value: nextValue,
|
||||
} = this.props;
|
||||
|
||||
if (nextValue[0] !== '/') {
|
||||
this.setActive(false);
|
||||
this.props.onResultCountChange(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextValue.indexOf(' ') === -1) { // delegate base command to command parser
|
||||
this.setActive(false);
|
||||
this.props.onResultCountChange(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isAppCommand(nextValue, this.props.channelId, this.props.currentTeamId, this.props.rootId)) {
|
||||
this.setActive(false);
|
||||
this.props.onResultCountChange(0);
|
||||
return;
|
||||
}
|
||||
|
||||
this.fetchAndShowAppCommandSuggestions(nextValue, this.props.channelId, this.props.currentTeamId, this.props.rootId);
|
||||
}
|
||||
|
||||
isAppCommand = (pretext: string, channelID: string, teamID = '', rootID?: string) => {
|
||||
this.appCommandParser.setChannelContext(channelID, teamID, rootID);
|
||||
return this.appCommandParser.isAppCommand(pretext);
|
||||
}
|
||||
|
||||
fetchAndShowAppCommandSuggestions = async (pretext: string, channelID: string, teamID = '', rootID?: string) => {
|
||||
this.appCommandParser.setChannelContext(channelID, teamID, rootID);
|
||||
const suggestions = await this.appCommandParser.getSuggestions(pretext);
|
||||
this.updateSuggestions(suggestions);
|
||||
}
|
||||
|
||||
updateSuggestions = (matches: ExtendedAutocompleteSuggestion[]) => {
|
||||
this.setState({
|
||||
active: Boolean(matches.length),
|
||||
dataSource: matches,
|
||||
});
|
||||
this.props.onResultCountChange(matches.length);
|
||||
}
|
||||
|
||||
completeSuggestion = (command: string) => {
|
||||
const {onChangeText} = this.props;
|
||||
analytics.trackCommand('complete_suggestion', `/${command} `);
|
||||
|
||||
// We are going to set a double / on iOS to prevent the auto correct from taking over and replacing it
|
||||
// with the wrong value, this is a hack but I could not found another way to solve it
|
||||
let completedDraft = `/${command} `;
|
||||
if (Platform.OS === 'ios') {
|
||||
completedDraft = `//${command} `;
|
||||
}
|
||||
|
||||
onChangeText(completedDraft);
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
// This is the second part of the hack were we replace the double / with just one
|
||||
// after the auto correct vanished
|
||||
setTimeout(() => {
|
||||
onChangeText(completedDraft.replace(`//${command} `, `/${command} `));
|
||||
});
|
||||
}
|
||||
|
||||
if (!isMinimumServerVersion(Client4.getServerVersion(), 5, 24)) {
|
||||
this.setState({
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
completeUserSuggestion = (base: string): (username: string) => void => {
|
||||
return () => {
|
||||
this.completeSuggestion(base);
|
||||
};
|
||||
}
|
||||
|
||||
completeChannelMention = (base: string): (channelName: string) => void => {
|
||||
return () => {
|
||||
this.completeSuggestion(base);
|
||||
};
|
||||
}
|
||||
|
||||
keyExtractor = (item: ExtendedAutocompleteSuggestion): string => item.Suggestion + item.type + item.item;
|
||||
|
||||
renderItem = ({item}: {item: ExtendedAutocompleteSuggestion}) => {
|
||||
switch (item.type) {
|
||||
case COMMAND_SUGGESTION_USER:
|
||||
return (
|
||||
<AtMentionItem
|
||||
userId={item.item || ''}
|
||||
onPress={this.completeUserSuggestion(item.Complete)}
|
||||
testID={`autocomplete.at_mention.item.${item.item}`}
|
||||
/>
|
||||
);
|
||||
case COMMAND_SUGGESTION_CHANNEL:
|
||||
return (
|
||||
<ChannelMentionItem
|
||||
channelId={item.item || ''}
|
||||
onPress={this.completeChannelMention(item.Complete)}
|
||||
testID={`autocomplete.channel_mention.item.${item.item}`}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<SlashSuggestionItem
|
||||
description={item.Description}
|
||||
hint={item.Hint}
|
||||
onPress={this.completeSuggestion}
|
||||
theme={this.props.theme}
|
||||
suggestion={item.Suggestion}
|
||||
complete={item.Complete}
|
||||
icon={item.IconData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {maxListHeight, theme, nestedScrollEnabled} = this.props;
|
||||
|
||||
if (!this.state.active) {
|
||||
// If we are not in an active state return null so nothing is rendered
|
||||
// other components are not blocked.
|
||||
return null;
|
||||
}
|
||||
|
||||
const style = getStyleFromTheme(theme);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
testID='app_slash_suggestion.list'
|
||||
keyboardShouldPersistTaps='always'
|
||||
style={[style.listView, {maxHeight: maxListHeight}]}
|
||||
data={this.state.dataSource}
|
||||
keyExtractor={this.keyExtractor}
|
||||
removeClippedSubviews={true}
|
||||
renderItem={this.renderItem}
|
||||
nestedScrollEnabled={nestedScrollEnabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
||||
return {
|
||||
listView: {
|
||||
flex: 1,
|
||||
backgroundColor: theme.centerChannelBg,
|
||||
paddingTop: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
import {getTheme} from '@mm-redux/selectors/entities/preferences';
|
||||
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
|
||||
import {GlobalState} from '@mm-redux/types/store';
|
||||
import {appsEnabled} from '@utils/apps';
|
||||
|
||||
import AppSlashSuggestion from './app_slash_suggestion';
|
||||
|
||||
function mapStateToProps(state: GlobalState) {
|
||||
return {
|
||||
currentTeamId: getCurrentTeamId(state),
|
||||
theme: getTheme(state),
|
||||
appsEnabled: appsEnabled(state),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps)(AppSlashSuggestion);
|
||||
|
|
@ -212,33 +212,6 @@ describe('components/autocomplete/slash_suggestion', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
test('should show commands from app sub commands', async (done) => {
|
||||
const props: Props = {
|
||||
...baseProps,
|
||||
};
|
||||
|
||||
const wrapper = shallow<SlashSuggestion>(
|
||||
<SlashSuggestion {...props}/>,
|
||||
{context: {intl}},
|
||||
);
|
||||
wrapper.setProps({value: '/jira i', suggestions: []});
|
||||
|
||||
const expected: AutocompleteSuggestion[] = [
|
||||
{
|
||||
Complete: 'jira issue',
|
||||
Description: 'Interact with Jira issues',
|
||||
Hint: 'Issue hint',
|
||||
IconData: 'Issue icon',
|
||||
Suggestion: 'issue',
|
||||
},
|
||||
];
|
||||
|
||||
setTimeout(() => {
|
||||
expect(wrapper.state('dataSource')).toEqual(expected);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
test('should avoid using app commands when apps are disabled', async () => {
|
||||
const props: Props = {
|
||||
...baseProps,
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ export default class SlashSuggestion extends PureComponent<Props, State> {
|
|||
|
||||
constructor(props: Props, context: any) {
|
||||
super(props);
|
||||
this.appCommandParser = new AppCommandParser(null, context.intl, props.channelId, props.rootId);
|
||||
this.appCommandParser = new AppCommandParser(null, context.intl, props.channelId, props.currentTeamId, props.rootId);
|
||||
}
|
||||
|
||||
setActive(active: boolean) {
|
||||
|
|
@ -109,12 +109,9 @@ export default class SlashSuggestion extends PureComponent<Props, State> {
|
|||
this.setLastCommandRequest(Date.now());
|
||||
}
|
||||
|
||||
this.showBaseCommands(nextValue, nextCommands, prevProps.channelId, prevProps.rootId);
|
||||
this.showBaseCommands(nextValue, nextCommands, this.props.channelId, this.props.currentTeamId, this.props.rootId);
|
||||
} else if (isMinimumServerVersion(Client4.getServerVersion(), 5, 24)) {
|
||||
// If this is an app command, then hand it off to the app command parser.
|
||||
if (this.props.appsEnabled && this.isAppCommand(nextValue, prevProps.channelId, prevProps.rootId)) {
|
||||
this.fetchAndShowAppCommandSuggestions(nextValue, prevProps.channelId, prevProps.rootId);
|
||||
} else if (nextSuggestions === prevProps.suggestions) {
|
||||
if (nextSuggestions === prevProps.suggestions) {
|
||||
const args = {
|
||||
channel_id: prevProps.channelId,
|
||||
team_id: prevProps.currentTeamId,
|
||||
|
|
@ -135,11 +132,11 @@ export default class SlashSuggestion extends PureComponent<Props, State> {
|
|||
}
|
||||
}
|
||||
|
||||
showBaseCommands = (text: string, commands: Command[], channelID: string, rootID?: string) => {
|
||||
showBaseCommands = (text: string, commands: Command[], channelID: string, teamID = '', rootID?: string) => {
|
||||
let matches: AutocompleteSuggestion[] = [];
|
||||
|
||||
if (this.props.appsEnabled) {
|
||||
const appCommands = this.getAppBaseCommandSuggestions(text, channelID, rootID);
|
||||
const appCommands = this.getAppBaseCommandSuggestions(text, channelID, teamID, rootID);
|
||||
matches = matches.concat(appCommands);
|
||||
}
|
||||
|
||||
|
|
@ -155,19 +152,8 @@ export default class SlashSuggestion extends PureComponent<Props, State> {
|
|||
this.updateSuggestions(matches);
|
||||
}
|
||||
|
||||
isAppCommand = (pretext: string, channelID: string, rootID?: string) => {
|
||||
this.appCommandParser.setChannelContext(channelID, rootID);
|
||||
return this.appCommandParser.isAppCommand(pretext);
|
||||
}
|
||||
|
||||
fetchAndShowAppCommandSuggestions = async (pretext: string, channelID: string, rootID?: string) => {
|
||||
this.appCommandParser.setChannelContext(channelID, rootID);
|
||||
const suggestions = await this.appCommandParser.getSuggestions(pretext);
|
||||
this.updateSuggestions(suggestions);
|
||||
}
|
||||
|
||||
getAppBaseCommandSuggestions = (pretext: string, channelID: string, rootID?: string): AutocompleteSuggestion[] => {
|
||||
this.appCommandParser.setChannelContext(channelID, rootID);
|
||||
getAppBaseCommandSuggestions = (pretext: string, channelID: string, teamID = '', rootID?: string): AutocompleteSuggestion[] => {
|
||||
this.appCommandParser.setChannelContext(channelID, teamID, rootID);
|
||||
const suggestions = this.appCommandParser.getSuggestionsBase(pretext);
|
||||
return suggestions;
|
||||
}
|
||||
|
|
@ -225,12 +211,6 @@ export default class SlashSuggestion extends PureComponent<Props, State> {
|
|||
onChangeText(completedDraft.replace(`//${command} `, `/${command} `));
|
||||
});
|
||||
}
|
||||
|
||||
if (!isMinimumServerVersion(Client4.getServerVersion(), 5, 24)) {
|
||||
this.setState({
|
||||
active: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
keyExtractor = (item: Command & AutocompleteSuggestion): string => item.id || item.Suggestion;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {useSafeAreaInsets} from 'react-native-safe-area-context';
|
|||
import {SvgXml} from 'react-native-svg';
|
||||
|
||||
import TouchableWithFeedback from '@components/touchable_with_feedback';
|
||||
import {COMMAND_SUGGESTION_ERROR} from '@mm-redux/constants/apps';
|
||||
import {Theme} from '@mm-redux/types/preferences';
|
||||
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
||||
|
||||
|
|
@ -105,7 +106,7 @@ const SlashSuggestionItem = (props: Props) => {
|
|||
source={slashIcon}
|
||||
/>
|
||||
);
|
||||
if (props.icon === 'error') {
|
||||
if (props.icon === COMMAND_SUGGESTION_ERROR) {
|
||||
image = (
|
||||
<Image
|
||||
style={style.iconColor}
|
||||
|
|
|
|||
|
|
@ -45,3 +45,7 @@ export const AppFieldTypes: { [name: string]: AppFieldType } = {
|
|||
CHANNEL: 'channel',
|
||||
MARKDOWN: 'markdown',
|
||||
};
|
||||
|
||||
export const COMMAND_SUGGESTION_ERROR = 'error';
|
||||
export const COMMAND_SUGGESTION_CHANNEL = 'channel';
|
||||
export const COMMAND_SUGGESTION_USER = 'user';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {createSelector} from 'reselect';
|
||||
|
||||
import {AppBinding} from '@mm-redux/types/apps';
|
||||
import {GlobalState} from '@mm-redux/types/store';
|
||||
import {appsEnabled} from '@utils/apps';
|
||||
|
||||
export function getAppsBindings(state: GlobalState, location?: string): AppBinding[] {
|
||||
if (!state.entities.apps.bindings) {
|
||||
|
|
@ -14,3 +18,18 @@ export function getAppsBindings(state: GlobalState, location?: string): AppBindi
|
|||
}
|
||||
return state.entities.apps.bindings;
|
||||
}
|
||||
|
||||
export const makeAppBindingsSelector = (location: string) => {
|
||||
return createSelector(
|
||||
(state: GlobalState) => state.entities.apps.bindings,
|
||||
(state: GlobalState) => appsEnabled(state),
|
||||
(bindings: AppBinding[], areAppsEnabled: boolean) => {
|
||||
if (!areAppsEnabled || !bindings) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const headerBindings = bindings.filter((b) => b.location === location);
|
||||
return headerBindings.reduce((accum: AppBinding[], current: AppBinding) => accum.concat(current.bindings || []), []);
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {UserProfile} from './users';
|
||||
|
||||
export type AppManifest = {
|
||||
app_id: string;
|
||||
display_name: string;
|
||||
|
|
@ -177,6 +179,14 @@ export type AppField = {
|
|||
max_length?: number;
|
||||
};
|
||||
|
||||
export type UserAutocomplete = {
|
||||
users: UserProfile[];
|
||||
|
||||
// out_of_channel contains users that aren't in the given channel. It's only populated when autocompleting users in
|
||||
// a given channel ID.
|
||||
out_of_channel?: UserProfile[];
|
||||
};
|
||||
|
||||
export type AutocompleteSuggestion = {
|
||||
suggestion: string;
|
||||
complete?: string;
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export type Channel = {
|
|||
status?: string;
|
||||
fake?: boolean;
|
||||
group_constrained: boolean;
|
||||
shared?: boolean;
|
||||
};
|
||||
export type ChannelWithTeamData = Channel & {
|
||||
team_display_name: string;
|
||||
|
|
|
|||
|
|
@ -11,15 +11,16 @@ import {
|
|||
getCurrentUser, getProfilesInCurrentChannel,
|
||||
getProfilesNotInCurrentChannel, getProfilesInCurrentTeam,
|
||||
} from '@mm-redux/selectors/entities/users';
|
||||
import {GlobalState} from '@mm-redux/types/store';
|
||||
import {sortChannelsByDisplayName} from '@mm-redux/utils/channel_utils';
|
||||
import {sortByUsername} from '@mm-redux/utils/user_utils';
|
||||
import {getCurrentLocale} from '@selectors/i18n';
|
||||
|
||||
export const getMatchTermForAtMention = (() => {
|
||||
let lastMatchTerm = null;
|
||||
let lastValue;
|
||||
let lastIsSearch;
|
||||
return (value, isSearch) => {
|
||||
let lastMatchTerm: string | null = null;
|
||||
let lastValue: string;
|
||||
let lastIsSearch: boolean;
|
||||
return (value: string, isSearch: boolean) => {
|
||||
if (value !== lastValue || isSearch !== lastIsSearch) {
|
||||
const regex = isSearch ? Autocomplete.AT_MENTION_SEARCH_REGEX : Autocomplete.AT_MENTION_REGEX;
|
||||
let term = value;
|
||||
|
|
@ -41,10 +42,10 @@ export const getMatchTermForAtMention = (() => {
|
|||
})();
|
||||
|
||||
export const getMatchTermForChannelMention = (() => {
|
||||
let lastMatchTerm = null;
|
||||
let lastValue;
|
||||
let lastIsSearch;
|
||||
return (value, isSearch) => {
|
||||
let lastMatchTerm: string | null = null;
|
||||
let lastValue: string;
|
||||
let lastIsSearch: boolean;
|
||||
return (value: string, isSearch: boolean) => {
|
||||
if (value !== lastValue || isSearch !== lastIsSearch) {
|
||||
const regex = isSearch ? Autocomplete.CHANNEL_MENTION_SEARCH_REGEX : Autocomplete.CHANNEL_MENTION_REGEX;
|
||||
const match = value.match(regex);
|
||||
|
|
@ -53,7 +54,7 @@ export const getMatchTermForChannelMention = (() => {
|
|||
if (match) {
|
||||
if (isSearch) {
|
||||
lastMatchTerm = match[1].toLowerCase();
|
||||
} else if (match.index > 0 && value[match.index - 1] === '~') {
|
||||
} else if (match.index && match.index > 0 && value[match.index - 1] === '~') {
|
||||
lastMatchTerm = null;
|
||||
} else {
|
||||
lastMatchTerm = match[2].toLowerCase();
|
||||
|
|
@ -68,7 +69,7 @@ export const getMatchTermForChannelMention = (() => {
|
|||
|
||||
export const filterMembersInChannel = createSelector(
|
||||
getProfilesInCurrentChannel,
|
||||
(state, matchTerm) => matchTerm,
|
||||
(state: GlobalState, matchTerm: string) => matchTerm,
|
||||
(profilesInChannel, matchTerm) => {
|
||||
if (matchTerm === null) {
|
||||
return null;
|
||||
|
|
@ -95,7 +96,7 @@ export const filterMembersInChannel = createSelector(
|
|||
|
||||
export const filterMembersNotInChannel = createSelector(
|
||||
getProfilesNotInCurrentChannel,
|
||||
(state, matchTerm) => matchTerm,
|
||||
(state: GlobalState, matchTerm: string) => matchTerm,
|
||||
(profilesNotInChannel, matchTerm) => {
|
||||
if (matchTerm === null) {
|
||||
return null;
|
||||
|
|
@ -127,7 +128,7 @@ export const filterMembersNotInChannel = createSelector(
|
|||
export const filterMembersInCurrentTeam = createSelector(
|
||||
getProfilesInCurrentTeam,
|
||||
getCurrentUser,
|
||||
(state, matchTerm) => matchTerm,
|
||||
(state: GlobalState, matchTerm: string) => matchTerm,
|
||||
(profilesInTeam, currentUser, matchTerm) => {
|
||||
if (matchTerm === null) {
|
||||
return null;
|
||||
|
|
@ -151,7 +152,7 @@ export const filterMembersInCurrentTeam = createSelector(
|
|||
|
||||
export const filterMyChannels = createSelector(
|
||||
getMyChannels,
|
||||
(state, opts) => opts,
|
||||
(state: GlobalState, opts: any) => opts,
|
||||
(myChannels, matchTerm) => {
|
||||
if (matchTerm === null) {
|
||||
return null;
|
||||
|
|
@ -175,7 +176,7 @@ export const filterMyChannels = createSelector(
|
|||
|
||||
export const filterOtherChannels = createSelector(
|
||||
getOtherChannels,
|
||||
(state, matchTerm) => matchTerm,
|
||||
(state: GlobalState, matchTerm: string) => matchTerm,
|
||||
(otherChannels, matchTerm) => {
|
||||
if (matchTerm === null) {
|
||||
return null;
|
||||
|
|
@ -198,7 +199,7 @@ export const filterPublicChannels = createSelector(
|
|||
getMyChannels,
|
||||
getOtherChannels,
|
||||
getCurrentLocale,
|
||||
(state, matchTerm) => matchTerm,
|
||||
(state: GlobalState, matchTerm: string) => matchTerm,
|
||||
getConfig,
|
||||
(myChannels, otherChannels, locale, matchTerm, config) => {
|
||||
if (matchTerm === null) {
|
||||
|
|
@ -230,7 +231,7 @@ export const filterPublicChannels = createSelector(
|
|||
|
||||
export const filterPrivateChannels = createSelector(
|
||||
getMyChannels,
|
||||
(state, matchTerm) => matchTerm,
|
||||
(state: GlobalState, matchTerm: string) => matchTerm,
|
||||
getConfig,
|
||||
(myChannels, matchTerm, config) => {
|
||||
if (matchTerm === null) {
|
||||
|
|
@ -261,7 +262,7 @@ export const filterPrivateChannels = createSelector(
|
|||
export const filterDirectAndGroupMessages = createSelector(
|
||||
getMyChannels,
|
||||
(state) => state.entities.channels.channels,
|
||||
(state, matchTerm) => matchTerm,
|
||||
(state: GlobalState, matchTerm: string) => matchTerm,
|
||||
(myChannels, originalChannels, matchTerm) => {
|
||||
if (matchTerm === null) {
|
||||
return null;
|
||||
|
|
@ -293,9 +294,9 @@ export const filterDirectAndGroupMessages = createSelector(
|
|||
);
|
||||
|
||||
export const makeGetMatchTermForDateMention = () => {
|
||||
let lastMatchTerm = null;
|
||||
let lastValue;
|
||||
return (value) => {
|
||||
let lastMatchTerm: string | null = null;
|
||||
let lastValue: string;
|
||||
return (value: string) => {
|
||||
if (value !== lastValue) {
|
||||
const regex = Autocomplete.DATE_MENTION_SEARCH_REGEX;
|
||||
const match = value.match(regex);
|
||||
|
|
@ -8,10 +8,12 @@ import {getChannelByName, getChannelsInCurrentTeam, getMyChannelMemberships} fro
|
|||
import {getConfig} from '@mm-redux/selectors/entities/general';
|
||||
import {getTeamByName} from '@mm-redux/selectors/entities/teams';
|
||||
import {getCurrentUserId, getUser} from '@mm-redux/selectors/entities/users';
|
||||
import {Channel} from '@mm-redux/types/channels';
|
||||
import {GlobalState} from '@mm-redux/types/store';
|
||||
import {isArchivedChannel} from '@mm-redux/utils/channel_utils';
|
||||
|
||||
const getOtherUserIdForDm = createSelector(
|
||||
(state, channel) => channel,
|
||||
(state: GlobalState, channel: Channel) => channel,
|
||||
getCurrentUserId,
|
||||
(channel, currentUserId) => {
|
||||
if (!channel) {
|
||||
|
|
@ -23,7 +25,7 @@ const getOtherUserIdForDm = createSelector(
|
|||
);
|
||||
|
||||
export const getChannelMembersForDm = createSelector(
|
||||
(state, channel) => getUser(state, getOtherUserIdForDm(state, channel)),
|
||||
(state: GlobalState, channel: Channel) => getUser(state, getOtherUserIdForDm(state, channel)),
|
||||
(otherUser) => {
|
||||
if (!otherUser) {
|
||||
return [];
|
||||
|
|
@ -34,7 +36,7 @@ export const getChannelMembersForDm = createSelector(
|
|||
);
|
||||
|
||||
export const getChannelNameForSearchAutocomplete = createSelector(
|
||||
(state, channelId) => state.entities.channels.channels[channelId],
|
||||
(state: GlobalState, channelId: string) => state.entities.channels.channels[channelId],
|
||||
(channel) => {
|
||||
if (channel && channel.display_name) {
|
||||
return channel.display_name;
|
||||
|
|
@ -43,8 +45,8 @@ export const getChannelNameForSearchAutocomplete = createSelector(
|
|||
},
|
||||
);
|
||||
|
||||
const getTeam = (state, channelName, teamName) => getTeamByName(state, teamName);
|
||||
const getChannel = (state, channelName) => getChannelByName(state, channelName);
|
||||
const getTeam = (state: GlobalState, channelName: string, teamName: string) => getTeamByName(state, teamName);
|
||||
const getChannel = (state: GlobalState, channelName: string) => getChannelByName(state, channelName);
|
||||
|
||||
export const getChannelReachable = createSelector(
|
||||
getTeam,
|
||||
122
app/utils/mentions/index.ts
Normal file
122
app/utils/mentions/index.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {
|
||||
autocompleteChannels,
|
||||
AutocompleteSuggestion,
|
||||
autocompleteUsersInChannel,
|
||||
Channel,
|
||||
COMMAND_SUGGESTION_CHANNEL,
|
||||
COMMAND_SUGGESTION_USER,
|
||||
DispatchFunc,
|
||||
GlobalState,
|
||||
UserAutocomplete,
|
||||
UserProfile,
|
||||
} from '@components/autocomplete/slash_suggestion/app_command_parser/app_command_parser_dependencies';
|
||||
|
||||
interface Store {
|
||||
dispatch: DispatchFunc;
|
||||
getState: () => GlobalState;
|
||||
}
|
||||
|
||||
export async function inTextMentionSuggestions(pretext: string, store: Store, channelID: string, teamID: string, delimiter = ''): Promise<AutocompleteSuggestion[] | null> {
|
||||
const separatedWords = pretext.split(' ');
|
||||
const incompleteLessLastWord = separatedWords.slice(0, -1).join(' ');
|
||||
const lastWord = separatedWords[separatedWords.length - 1];
|
||||
if (lastWord.startsWith('@')) {
|
||||
const {data} = await store.dispatch(autocompleteUsersInChannel(lastWord.substring(1), channelID));
|
||||
const users = await getUserSuggestions(data);
|
||||
users.forEach((u) => {
|
||||
let complete = incompleteLessLastWord ? incompleteLessLastWord + ' ' + u.Complete : u.Complete;
|
||||
if (delimiter) {
|
||||
complete = delimiter + complete;
|
||||
}
|
||||
u.Complete = complete;
|
||||
});
|
||||
return users;
|
||||
}
|
||||
|
||||
if (lastWord.startsWith('~') && !lastWord.startsWith('~~')) {
|
||||
const {data} = await store.dispatch(autocompleteChannels(teamID, lastWord.substring(1)));
|
||||
const channels = await getChannelSuggestions(data);
|
||||
channels.forEach((c) => {
|
||||
let complete = incompleteLessLastWord ? incompleteLessLastWord + ' ' + c.Complete : c.Complete;
|
||||
if (delimiter) {
|
||||
complete = delimiter + complete;
|
||||
}
|
||||
c.Complete = complete;
|
||||
});
|
||||
return channels;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getUserSuggestions(usersAutocomplete?: UserAutocomplete): Promise<AutocompleteSuggestion[]> {
|
||||
const notFoundSuggestions = [{
|
||||
Complete: '',
|
||||
Suggestion: '',
|
||||
Description: 'No user found',
|
||||
Hint: '',
|
||||
IconData: '',
|
||||
}];
|
||||
if (!usersAutocomplete) {
|
||||
return notFoundSuggestions;
|
||||
}
|
||||
|
||||
if (!usersAutocomplete.users.length && !usersAutocomplete.out_of_channel?.length) {
|
||||
return notFoundSuggestions;
|
||||
}
|
||||
|
||||
const items: AutocompleteSuggestion[] = [];
|
||||
usersAutocomplete.users.forEach((u) => {
|
||||
items.push(getUserSuggestion(u));
|
||||
});
|
||||
usersAutocomplete.out_of_channel?.forEach((u) => {
|
||||
items.push(getUserSuggestion(u));
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function getChannelSuggestions(channels?: Channel[]): Promise<AutocompleteSuggestion[]> {
|
||||
const notFoundSuggestion = [{
|
||||
Complete: '',
|
||||
Suggestion: '',
|
||||
Description: 'No channel found',
|
||||
Hint: '',
|
||||
IconData: '',
|
||||
}];
|
||||
if (!channels) {
|
||||
return notFoundSuggestion;
|
||||
}
|
||||
if (!channels.length) {
|
||||
return notFoundSuggestion;
|
||||
}
|
||||
|
||||
const items = channels.map((c) => {
|
||||
return {
|
||||
Complete: '~' + c.name,
|
||||
Suggestion: '',
|
||||
Description: '',
|
||||
Hint: '',
|
||||
IconData: '',
|
||||
type: COMMAND_SUGGESTION_CHANNEL,
|
||||
item: c.id,
|
||||
};
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function getUserSuggestion(u: UserProfile) {
|
||||
return {
|
||||
Complete: '@' + u.username,
|
||||
Suggestion: '',
|
||||
Description: '',
|
||||
Hint: '',
|
||||
IconData: '',
|
||||
type: COMMAND_SUGGESTION_USER,
|
||||
item: u.id,
|
||||
};
|
||||
}
|
||||
|
|
@ -26,7 +26,6 @@
|
|||
"apps.error.lookup.error_preparing_request": "Error preparing lookup request: {errorMessage}",
|
||||
"apps.error.parser": "Parsing error: {error}",
|
||||
"apps.error.parser.empty_value": "empty values are not allowed",
|
||||
"apps.error.parser.missing_binding": "Missing command bindings.",
|
||||
"apps.error.parser.missing_call": "Missing binding call.",
|
||||
"apps.error.parser.missing_field_value": "Field value is missing.",
|
||||
"apps.error.parser.missing_quote": "Matching double quote expected before end of input.",
|
||||
|
|
|
|||
Loading…
Reference in a new issue