Feature - Cloud Apps (#5226)

Co-authored-by: Daniel Espino García <larkox@gmail.com>
Co-authored-by: Michael Kochell <6913320+mickmister@users.noreply.github.com>
Co-authored-by: Ben Schumacher <ben.schumacher@mattermost.com>
This commit is contained in:
Daniel Espino García 2021-03-22 23:02:06 +01:00 committed by GitHub
parent 78c05270ba
commit 11c8454ee2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
91 changed files with 9303 additions and 137 deletions

116
app/actions/apps.ts Normal file
View file

@ -0,0 +1,116 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Client4} from '@mm-redux/client';
import {ActionFunc} from '@mm-redux/types/actions';
import {AppCallResponse, AppForm, AppCallRequest, AppCallType} from '@mm-redux/types/apps';
import {AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {handleGotoLocation} from '@mm-redux/actions/integrations';
import {showModal} from './navigation';
import {Theme} from '@mm-redux/types/preferences';
import CompassIcon from '@components/compass_icon';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import EphemeralStore from '@store/ephemeral_store';
import {makeCallErrorResponse} from '@utils/apps';
export function doAppCall<Res=unknown>(call: AppCallRequest, type: AppCallType, intl: any): ActionFunc {
return async (dispatch, getState) => {
try {
const res = await Client4.executeAppCall(call, type) as AppCallResponse<Res>;
const responseType = res.type || AppCallResponseTypes.OK;
switch (responseType) {
case AppCallResponseTypes.OK:
return {data: res};
case AppCallResponseTypes.ERROR:
return {error: res};
case AppCallResponseTypes.FORM: {
if (!res.form) {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.form.no_form',
defaultMessage: 'Response type is `form`, but no form was included in response.',
});
return {error: makeCallErrorResponse(errMsg)};
}
const screen = EphemeralStore.getNavigationTopComponentId();
if (type === AppCallTypes.SUBMIT && screen !== 'AppForm') {
showAppForm(res.form, call, getTheme(getState()));
}
return {data: res};
}
case AppCallResponseTypes.NAVIGATE:
if (!res.navigate_to_url) {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.navigate.no_url',
defaultMessage: 'Response type is `navigate`, but no url was included in response.',
});
return {error: makeCallErrorResponse(errMsg)};
}
if (type !== AppCallTypes.SUBMIT) {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.navigate.no_submit',
defaultMessage: 'Response type is `navigate`, but the call was not a submission.',
});
return {error: makeCallErrorResponse(errMsg)};
}
dispatch(handleGotoLocation(res.navigate_to_url, intl));
return {data: res};
default: {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: responseType,
});
return {error: makeCallErrorResponse(errMsg)};
}
}
} catch (error) {
const errMsg = error.message || intl.formatMessage({
id: 'apps.error.responses.unexpected_error',
defaultMessage: 'Received an unexpected error.',
});
return {error: makeCallErrorResponse(errMsg)};
}
};
}
const showAppForm = async (form: AppForm, call: AppCallRequest, theme: Theme) => {
const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
let submitButtons = [{
id: 'submit-form',
showAsAction: 'always',
text: 'Submit',
}];
if (form.submit_buttons) {
const options = form.fields.find((f) => f.name === form.submit_buttons)?.options;
const newButtons = options?.map((o) => {
return {
id: 'submit-form_' + o.value,
showAsAction: 'always',
text: o.label,
};
});
if (newButtons && newButtons.length > 0) {
submitButtons = newButtons;
}
}
const options = {
topBar: {
leftButtons: [{
id: 'close-dialog',
icon: closeButton,
}],
rightButtons: submitButtons,
},
};
const passProps = {form, call};
showModal('AppForm', form.title, passProps, options);
};

View file

@ -39,6 +39,8 @@ import {getChannelReachable} from '@selectors/channel';
import telemetry from '@telemetry';
import {isDirectChannelVisible, isGroupChannelVisible, getChannelSinceValue, privateChannelJoinPrompt} from '@utils/channels';
import {isPendingPost} from '@utils/general';
import {fetchAppBindings} from '@mm-redux/actions/apps';
import {appsEnabled} from '@utils/apps';
const MAX_RETRIES = 3;
@ -185,6 +187,7 @@ export function handleSelectChannel(channelId) {
return async (dispatch, getState) => {
const dt = Date.now();
const state = getState();
const {currentUserId} = state.entities.users;
const {channels, currentChannelId, myMembers} = state.entities.channels;
const {currentTeamId} = state.entities.teams;
const channel = channels[channelId];
@ -211,6 +214,10 @@ export function handleSelectChannel(channelId) {
dispatch(batchActions(actions, 'BATCH_SWITCH_CHANNEL'));
if (appsEnabled(state)) {
//TODO improve sync method
dispatch(fetchAppBindings(currentUserId, channelId));
}
console.log('channel switch to', channel?.display_name, channelId, (Date.now() - dt), 'ms'); //eslint-disable-line
}

View file

@ -1,39 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {IntegrationTypes} from '@mm-redux/action_types';
import {executeCommand as executeCommandService} from '@mm-redux/actions/integrations';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
export function executeCommand(message, channelId, rootId) {
return async (dispatch, getState) => {
const state = getState();
const teamId = getCurrentTeamId(state);
const args = {
channel_id: channelId,
team_id: teamId,
root_id: rootId,
parent_id: rootId,
};
let msg = message;
let cmdLength = msg.indexOf(' ');
if (cmdLength < 0) {
cmdLength = msg.length;
}
const cmd = msg.substring(0, cmdLength).toLowerCase();
msg = cmd + msg.substring(cmdLength, msg.length);
const {data, error} = await dispatch(executeCommandService(msg, args));
if (data?.trigger_id) { //eslint-disable-line camelcase
dispatch({type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID, data: data.trigger_id});
}
return {data, error};
};
}

View file

@ -0,0 +1,95 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {intlShape} from 'react-intl';
import {IntegrationTypes} from '@mm-redux/action_types';
import {executeCommand as executeCommandService} from '@mm-redux/actions/integrations';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {AppCallResponseTypes, AppCallTypes} from '@mm-redux/constants/apps';
import {DispatchFunc, GetStateFunc, ActionFunc} from '@mm-redux/types/actions';
import {AppCommandParser} from '@components/autocomplete/slash_suggestion/app_command_parser/app_command_parser';
import {doAppCall} from '@actions/apps';
import {appsEnabled} from '@utils/apps';
import {AppCallResponse} from '@mm-redux/types/apps';
import {sendEphemeralPost} from './post';
export function executeCommand(message: string, channelId: string, rootId: string, intl: typeof intlShape): ActionFunc {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
const state = getState();
const teamId = getCurrentTeamId(state);
const args = {
channel_id: channelId,
team_id: teamId,
root_id: rootId,
parent_id: rootId,
};
let msg = message;
let cmdLength = msg.indexOf(' ');
if (cmdLength < 0) {
cmdLength = msg.length;
}
const cmd = msg.substring(0, cmdLength).toLowerCase();
msg = cmd + msg.substring(cmdLength, msg.length);
const appsAreEnabled = appsEnabled(state);
if (appsAreEnabled) {
const parser = new AppCommandParser({dispatch, getState}, intl, args.channel_id, args.root_id);
if (parser.isAppCommand(msg)) {
const call = await parser.composeCallFromCommand(message);
const createErrorMessage = (errMessage: string) => {
return {error: {message: errMessage}};
};
if (!call) {
return createErrorMessage(intl.formatMessage({
id: 'mobile.commands.error_title',
defaultMessage: 'Error Executing Command',
}));
}
const res = await dispatch(doAppCall(call, AppCallTypes.SUBMIT, intl));
if (res.error) {
const errorResponse = res.error as AppCallResponse;
return createErrorMessage(errorResponse.error || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error.',
}));
}
const callResp = res.data as AppCallResponse;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
dispatch(sendEphemeralPost(callResp.markdown, args.channel_id, args.parent_id));
}
return {data: {}};
case AppCallResponseTypes.FORM:
case AppCallResponseTypes.NAVIGATE:
return {data: {}};
default:
return createErrorMessage(intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
}));
}
}
}
const {data, error} = await dispatch(executeCommandService(msg, args));
if (data?.trigger_id) { //eslint-disable-line camelcase
dispatch({type: IntegrationTypes.RECEIVED_DIALOG_TRIGGER_ID, data: data.trigger_id});
}
return {data, error};
};
}

View file

@ -28,6 +28,28 @@ import {getChannelSinceValue} from '@utils/channels';
import {getEmojisInPosts} from './emoji';
export function sendEphemeralPost(message, channelId = '', parentId = '') {
return async (dispatch, getState) => {
const timestamp = Date.now();
const post = {
id: generateId(),
user_id: '0',
channel_id: channelId || getCurrentChannelId(getState()),
message,
type: Posts.POST_TYPES.EPHEMERAL,
create_at: timestamp,
update_at: timestamp,
root_id: parentId,
parent_id: parentId,
props: {},
};
dispatch(receivedNewPost(post));
return {};
};
}
export function sendAddToChannelEphemeralPost(user, addedUsername, message, channelId, postRootId = '') {
return async (dispatch) => {
const timestamp = Date.now();

View file

@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {fetchAppBindings} from '@mm-redux/actions/apps';
import {getCurrentChannelId} from '@mm-redux/selectors/entities/common';
import {getCurrentUserId} from '@mm-redux/selectors/entities/users';
import {ActionResult, DispatchFunc, GetStateFunc} from '@mm-redux/types/actions';
import {appsEnabled} from '@utils/apps';
export function handleRefreshAppsBindings() {
return (dispatch: DispatchFunc, getState: GetStateFunc): ActionResult => {
const state = getState();
if (appsEnabled(state)) {
dispatch(fetchAppBindings(getCurrentUserId(state), getCurrentChannelId(state)));
}
return {data: true};
};
}

View file

@ -46,6 +46,7 @@ import {handleLeaveTeamEvent, handleUpdateTeamEvent, handleTeamAddedEvent} from
import {handleStatusChangedEvent, handleUserAddedEvent, handleUserRemovedEvent, handleUserRoleUpdated, handleUserUpdatedEvent} from './users';
import {getChannelSinceValue} from '@utils/channels';
import {getPostIdsInChannel} from '@mm-redux/selectors/entities/posts';
import {handleRefreshAppsBindings} from './apps';
export function init(additionalOptions: any = {}) {
return async (dispatch: DispatchFunc, getState: GetStateFunc) => {
@ -377,6 +378,9 @@ function handleEvent(msg: WebSocketMessage) {
return dispatch(handleOpenDialogEvent(msg));
case WebsocketEvents.RECEIVED_GROUP:
return dispatch(handleGroupUpdatedEvent(msg));
case WebsocketEvents.APPS_FRAMEWORK_REFRESH_BINDINGS: {
return dispatch(handleRefreshAppsBindings());
}
}
return {data: true};

View file

@ -0,0 +1,60 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`components/autocomplete/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}
extraData={
Object {
"active": true,
"dataSource": Array [
Object {
"Complete": "thetrigger",
"Description": "The Description",
"Hint": "The Hint",
"IconData": "iconurl.com",
"Suggestion": "/thetrigger",
},
],
"lastCommandRequest": 1234,
}
}
horizontal={false}
initialNumToRender={10}
keyExtractor={[Function]}
keyboardShouldPersistTaps="always"
maxToRenderPerBatch={10}
nestedScrollEnabled={false}
numColumns={1}
onEndReachedThreshold={2}
removeClippedSubviews={false}
renderItem={[Function]}
scrollEventThrottle={50}
style={
Array [
Object {
"backgroundColor": "#ffffff",
"borderRadius": 4,
"flex": 1,
"paddingTop": 8,
},
Object {
"maxHeight": 50,
},
]
}
testID="slash_suggestion.list"
updateCellsBatchingPeriod={50}
windowSize={21}
/>
`;

View file

@ -0,0 +1,958 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/* eslint-disable max-lines */
import {
thunk,
configureStore,
Client4,
AppBinding,
checkForExecuteSuggestion,
} from './tests/app_command_parser_test_dependencies';
import {
AppCallResponseTypes,
AppCallTypes,
AutocompleteSuggestion,
} from './app_command_parser_dependencies';
import {
AppCommandParser,
ParseState,
ParsedCommand,
} from './app_command_parser';
import {
reduxTestState,
testBindings,
} from './tests/app_command_parser_test_data';
const mockStore = configureStore([thunk]);
describe('AppCommandParser', () => {
const makeStore = async (bindings: AppBinding[]) => {
const initialState = {
...reduxTestState,
entities: {
...reduxTestState.entities,
apps: {bindings},
},
} as any;
const testStore = await mockStore(initialState);
return testStore;
};
const intl = {
formatMessage: (message: {id: string, defaultMessage: string}) => {
return message.defaultMessage;
},
};
let parser: AppCommandParser;
beforeEach(async () => {
const store = await makeStore(testBindings);
parser = new AppCommandParser(store as any, intl, 'current_channel_id', 'root_id');
});
type Variant = {
expectError?: string;
verify?(parsed: ParsedCommand): void;
}
type TC = {
title: string;
command: string;
submit: Variant;
autocomplete?: Variant; // if undefined, use same checks as submnit
}
const checkResult = (parsed: ParsedCommand, v: Variant) => {
if (v.expectError) {
expect(parsed.state).toBe(ParseState.Error);
expect(parsed.error).toBe(v.expectError);
} else {
// expect(parsed).toBe(1);
expect(parsed.error).toBe('');
expect(v.verify).toBeTruthy();
if (v.verify) {
v.verify(parsed);
}
}
};
describe('getSuggestionsBase', () => {
test('string matches 1', () => {
const res = parser.getSuggestionsBase('/');
expect(res).toHaveLength(2);
});
test('string matches 2', () => {
const res = parser.getSuggestionsBase('/ji');
expect(res).toHaveLength(1);
});
test('string matches 3', () => {
const res = parser.getSuggestionsBase('/jira');
expect(res).toHaveLength(1);
});
test('string matches case insensitive', () => {
const res = parser.getSuggestionsBase('/JiRa');
expect(res).toHaveLength(1);
});
test('string is past base command', () => {
const res = parser.getSuggestionsBase('/jira ');
expect(res).toHaveLength(0);
});
test('other command matches', () => {
const res = parser.getSuggestionsBase('/other');
expect(res).toHaveLength(1);
});
test('string does not match', () => {
const res = parser.getSuggestionsBase('/wrong');
expect(res).toHaveLength(0);
});
});
describe('matchBinding', () => {
const table: TC[] = [
{
title: 'full command',
command: '/jira issue create --project P --summary = "SUM MA RY" --verbose --epic=epic2',
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndCommand);
expect(parsed.binding?.label).toBe('create');
expect(parsed.incomplete).toBe('--project');
expect(parsed.incompleteStart).toBe(19);
}},
},
{
title: 'full command case insensitive',
command: '/JiRa IsSuE CrEaTe --PrOjEcT P --SuMmArY = "SUM MA RY" --VeRbOsE --EpIc=epic2',
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndCommand);
expect(parsed.binding?.label).toBe('create');
expect(parsed.incomplete).toBe('--PrOjEcT');
expect(parsed.incompleteStart).toBe(19);
}},
},
{
title: 'incomplete top command',
command: '/jir',
autocomplete: {expectError: '`{command}`: No matching command found in this workspace.'},
submit: {expectError: '`{command}`: No matching command found in this workspace.'},
},
{
title: 'no space after the top command',
command: '/jira',
autocomplete: {expectError: '`{command}`: No matching command found in this workspace.'},
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.Command);
expect(parsed.binding?.label).toBe('jira');
}},
},
{
title: 'space after the top command',
command: '/jira ',
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.Command);
expect(parsed.binding?.label).toBe('jira');
}},
},
{
title: 'middle of subcommand',
command: '/jira iss',
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.Command);
expect(parsed.binding?.label).toBe('jira');
expect(parsed.incomplete).toBe('iss');
expect(parsed.incompleteStart).toBe(9);
}},
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndCommand);
expect(parsed.binding?.label).toBe('jira');
expect(parsed.incomplete).toBe('iss');
expect(parsed.incompleteStart).toBe(9);
}},
},
{
title: 'second subcommand, no space',
command: '/jira issue',
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.Command);
expect(parsed.binding?.label).toBe('jira');
expect(parsed.incomplete).toBe('issue');
expect(parsed.incompleteStart).toBe(6);
}},
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.Command);
expect(parsed.binding?.label).toBe('issue');
expect(parsed.location).toBe('/jira/issue');
}},
},
{
title: 'token after the end of bindings, no space',
command: '/jira issue create something',
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.Command);
expect(parsed.binding?.label).toBe('create');
expect(parsed.incomplete).toBe('something');
expect(parsed.incompleteStart).toBe(20);
}},
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndCommand);
expect(parsed.binding?.label).toBe('create');
expect(parsed.incomplete).toBe('something');
expect(parsed.incompleteStart).toBe(20);
}},
},
{
title: 'token after the end of bindings, with space',
command: '/jira issue create something ',
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndCommand);
expect(parsed.binding?.label).toBe('create');
expect(parsed.incomplete).toBe('something');
expect(parsed.incompleteStart).toBe(20);
}},
},
];
table.forEach((tc) => {
test(tc.title, async () => {
const bindings = testBindings[0].bindings as AppBinding[];
let a = new ParsedCommand(tc.command, parser, intl);
a = await a.matchBinding(bindings, true);
checkResult(a, tc.autocomplete || tc.submit);
let s = new ParsedCommand(tc.command, parser, intl);
s = await s.matchBinding(bindings, false);
checkResult(s, tc.submit);
});
});
});
describe('parseForm', () => {
const table: TC[] = [
{
title: 'happy full create',
command: '/jira issue create --project `P 1` --summary "SUM MA RY" --verbose --epic=epic2',
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('create');
expect(parsed.form?.call?.path).toBe('/create-issue');
expect(parsed.incomplete).toBe('epic2');
expect(parsed.incompleteStart).toBe(75);
expect(parsed.values?.project).toBe('P 1');
expect(parsed.values?.epic).toBeUndefined();
expect(parsed.values?.summary).toBe('SUM MA RY');
expect(parsed.values?.verbose).toBe('true');
}},
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('create');
expect(parsed.form?.call?.path).toBe('/create-issue');
expect(parsed.values?.project).toBe('P 1');
expect(parsed.values?.epic).toBe('epic2');
expect(parsed.values?.summary).toBe('SUM MA RY');
expect(parsed.values?.verbose).toBe('true');
}},
},
{
title: 'happy full create case insensitive',
command: '/JiRa IsSuE CrEaTe --PrOjEcT `P 1` --SuMmArY "SUM MA RY" --VeRbOsE --EpIc=epic2',
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('create');
expect(parsed.form?.call?.path).toBe('/create-issue');
expect(parsed.incomplete).toBe('epic2');
expect(parsed.incompleteStart).toBe(75);
expect(parsed.values?.project).toBe('P 1');
expect(parsed.values?.epic).toBeUndefined();
expect(parsed.values?.summary).toBe('SUM MA RY');
expect(parsed.values?.verbose).toBe('true');
}},
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('create');
expect(parsed.form?.call?.path).toBe('/create-issue');
expect(parsed.values?.project).toBe('P 1');
expect(parsed.values?.epic).toBe('epic2');
expect(parsed.values?.summary).toBe('SUM MA RY');
expect(parsed.values?.verbose).toBe('true');
}},
},
{
title: 'partial epic',
command: '/jira issue create --project KT --summary "great feature" --epic M',
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('create');
expect(parsed.form?.call?.path).toBe('/create-issue');
expect(parsed.incomplete).toBe('M');
expect(parsed.incompleteStart).toBe(65);
expect(parsed.values?.project).toBe('KT');
expect(parsed.values?.epic).toBeUndefined();
}},
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('create');
expect(parsed.form?.call?.path).toBe('/create-issue');
expect(parsed.values?.epic).toBe('M');
}},
},
{
title: 'happy full view',
command: '/jira issue view --project=`P 1` MM-123',
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('view');
expect(parsed.form?.call?.path).toBe('/view-issue');
expect(parsed.incomplete).toBe('MM-123');
expect(parsed.incompleteStart).toBe(33);
expect(parsed.values?.project).toBe('P 1');
expect(parsed.values?.issue).toBe(undefined);
}},
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('view');
expect(parsed.form?.call?.path).toBe('/view-issue');
expect(parsed.values?.project).toBe('P 1');
expect(parsed.values?.issue).toBe('MM-123');
}},
},
{
title: 'happy view no parameters',
command: '/jira issue view ',
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.StartParameter);
expect(parsed.binding?.label).toBe('view');
expect(parsed.form?.call?.path).toBe('/view-issue');
expect(parsed.incomplete).toBe('');
expect(parsed.incompleteStart).toBe(17);
expect(parsed.values).toEqual({});
}},
},
{
title: 'happy create flag no value',
command: '/jira issue create --summary ',
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.FlagValueSeparator);
expect(parsed.binding?.label).toBe('create');
expect(parsed.form?.call?.path).toBe('/create-issue');
expect(parsed.incomplete).toBe('');
expect(parsed.values).toEqual({});
}},
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndValue);
expect(parsed.binding?.label).toBe('create');
expect(parsed.form?.call?.path).toBe('/create-issue');
expect(parsed.incomplete).toBe('');
expect(parsed.values).toEqual({
summary: '',
});
}},
},
{
title: 'error: unmatched tick',
command: '/jira issue view --project `P 1',
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.TickValue);
expect(parsed.binding?.label).toBe('view');
expect(parsed.form?.call?.path).toBe('/view-issue');
expect(parsed.incomplete).toBe('P 1');
expect(parsed.incompleteStart).toBe(27);
expect(parsed.values?.project).toBe(undefined);
expect(parsed.values?.issue).toBe(undefined);
}},
submit: {expectError: 'Matching tick quote expected before end of input.'},
},
{
title: 'error: unmatched quote',
command: '/jira issue view --project "P \\1',
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.QuotedValue);
expect(parsed.binding?.label).toBe('view');
expect(parsed.form?.call?.path).toBe('/view-issue');
expect(parsed.incomplete).toBe('P 1');
expect(parsed.incompleteStart).toBe(27);
expect(parsed.values?.project).toBe(undefined);
expect(parsed.values?.issue).toBe(undefined);
}},
submit: {expectError: 'Matching double quote expected before end of input.'},
},
{
title: 'missing required fields not a problem for parseCommand',
command: '/jira issue view --project "P 1"',
autocomplete: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndQuotedValue);
expect(parsed.binding?.label).toBe('view');
expect(parsed.form?.call?.path).toBe('/view-issue');
expect(parsed.incomplete).toBe('P 1');
expect(parsed.incompleteStart).toBe(27);
expect(parsed.values?.project).toBe(undefined);
expect(parsed.values?.issue).toBe(undefined);
}},
submit: {verify: (parsed: ParsedCommand): void => {
expect(parsed.state).toBe(ParseState.EndQuotedValue);
expect(parsed.binding?.label).toBe('view');
expect(parsed.form?.call?.path).toBe('/view-issue');
expect(parsed.values?.project).toBe('P 1');
expect(parsed.values?.issue).toBe(undefined);
}},
},
{
title: 'error: invalid flag',
command: '/jira issue view --wrong test',
submit: {expectError: 'Command does not accept flag `{flagName}`.'},
},
{
title: 'error: unexpected positional',
command: '/jira issue create wrong',
submit: {expectError: 'Command does not accept {positionX} positional arguments.'},
},
{
title: 'error: multiple equal signs',
command: '/jira issue create --project == test',
submit: {expectError: 'Multiple `=` signs are not allowed.'},
},
];
table.forEach((tc) => {
test(tc.title, async () => {
const bindings = testBindings[0].bindings as AppBinding[];
let a = new ParsedCommand(tc.command, parser, intl);
a = await a.matchBinding(bindings, true);
a = a.parseForm(true);
checkResult(a, tc.autocomplete || tc.submit);
let s = new ParsedCommand(tc.command, parser, intl);
s = await s.matchBinding(bindings, false);
s = s.parseForm(false);
checkResult(s, tc.submit);
});
});
});
describe('getSuggestions', () => {
test('just the app command', async () => {
const suggestions = await parser.getSuggestions('/jira');
expect(suggestions).toEqual([]);
});
test('subcommand 1', async () => {
const suggestions = await parser.getSuggestions('/jira ');
expect(suggestions).toEqual([
{
Suggestion: 'issue',
Complete: 'jira issue',
Hint: 'Issue hint',
IconData: 'Issue icon',
Description: 'Interact with Jira issues',
},
]);
});
test('subcommand 1 case insensitive', async () => {
const suggestions = await parser.getSuggestions('/JiRa ');
expect(suggestions).toEqual([
{
Suggestion: 'issue',
Complete: 'JiRa issue',
Hint: 'Issue hint',
IconData: 'Issue icon',
Description: 'Interact with Jira issues',
},
]);
});
test('subcommand 2', async () => {
const suggestions = await parser.getSuggestions('/jira issue');
expect(suggestions).toEqual([
{
Suggestion: 'issue',
Complete: 'jira issue',
Hint: 'Issue hint',
IconData: 'Issue icon',
Description: 'Interact with Jira issues',
},
]);
});
test('subcommand 2 case insensitive', async () => {
const suggestions = await parser.getSuggestions('/JiRa IsSuE');
expect(suggestions).toEqual([
{
Suggestion: 'issue',
Complete: 'JiRa issue',
Hint: 'Issue hint',
IconData: 'Issue icon',
Description: 'Interact with Jira issues',
},
]);
});
test('subcommand 2 with a space', async () => {
const suggestions = await parser.getSuggestions('/jira issue ');
expect(suggestions).toEqual([
{
Suggestion: 'view',
Complete: 'jira issue view',
Hint: '',
IconData: '',
Description: 'View details of a Jira issue',
},
{
Suggestion: 'create',
Complete: 'jira issue create',
Hint: 'Create hint',
IconData: 'Create icon',
Description: 'Create a new Jira issue',
},
]);
});
test('subcommand 2 with a space case insensitive', async () => {
const suggestions = await parser.getSuggestions('/JiRa IsSuE ');
expect(suggestions).toEqual([
{
Suggestion: 'view',
Complete: 'JiRa IsSuE view',
Hint: '',
IconData: '',
Description: 'View details of a Jira issue',
},
{
Suggestion: 'create',
Complete: 'JiRa IsSuE create',
Hint: 'Create hint',
IconData: 'Create icon',
Description: 'Create a new Jira issue',
},
]);
});
test('subcommand 3 partial', async () => {
const suggestions = await parser.getSuggestions('/jira issue c');
expect(suggestions).toEqual([
{
Suggestion: 'create',
Complete: 'jira issue create',
Hint: 'Create hint',
IconData: 'Create icon',
Description: 'Create a new Jira issue',
},
]);
});
test('subcommand 3 partial case insensitive', async () => {
const suggestions = await parser.getSuggestions('/JiRa IsSuE C');
expect(suggestions).toEqual([
{
Suggestion: 'create',
Complete: 'JiRa IsSuE create',
Hint: 'Create hint',
IconData: 'Create icon',
Description: 'Create a new Jira issue',
},
]);
});
test('view just after subcommand (positional)', async () => {
const suggestions = await parser.getSuggestions('/jira issue view ');
expect(suggestions).toEqual([
{
Complete: 'jira issue view',
Description: 'The Jira issue key',
Hint: '',
IconData: '',
Suggestion: '',
},
]);
});
test('view flags just after subcommand', async () => {
let suggestions = await parser.getSuggestions('/jira issue view -');
expect(suggestions).toEqual([
{
Complete: 'jira issue view --project',
Description: 'The Jira project description',
Hint: 'The Jira project hint',
IconData: '',
Suggestion: '--project',
},
]);
suggestions = await parser.getSuggestions('/jira issue view --');
expect(suggestions).toEqual([
{
Complete: 'jira issue view --project',
Description: 'The Jira project description',
Hint: 'The Jira project hint',
IconData: '',
Suggestion: '--project',
},
]);
});
test('create flags just after subcommand', async () => {
const suggestions = await parser.getSuggestions('/jira issue create ');
let executeCommand: AutocompleteSuggestion[] = [];
if (checkForExecuteSuggestion) {
executeCommand = [
{
Complete: 'jira issue create _execute_current_command',
Description: 'Select this option or use Ctrl+Enter to execute the current command.',
Hint: '',
IconData: '_execute_current_command',
Suggestion: 'Execute Current Command',
},
];
}
expect(suggestions).toEqual([
...executeCommand,
{
Complete: 'jira issue create --project',
Description: 'The Jira project description',
Hint: 'The Jira project hint',
IconData: 'Create icon',
Suggestion: '--project',
},
{
Complete: 'jira issue create --summary',
Description: 'The Jira issue summary',
Hint: 'The thing is working great!',
IconData: 'Create icon',
Suggestion: '--summary',
},
{
Complete: 'jira issue create --verbose',
Description: 'display details',
Hint: 'yes or no!',
IconData: 'Create icon',
Suggestion: '--verbose',
},
{
Complete: 'jira issue create --epic',
Description: 'The Jira epic',
Hint: 'The thing is working great!',
IconData: 'Create icon',
Suggestion: '--epic',
},
]);
});
test('used flags do not appear', async () => {
const suggestions = await parser.getSuggestions('/jira issue create --project KT ');
let executeCommand: AutocompleteSuggestion[] = [];
if (checkForExecuteSuggestion) {
executeCommand = [
{
Complete: 'jira issue create --project KT _execute_current_command',
Description: 'Select this option or use Ctrl+Enter to execute the current command.',
Hint: '',
IconData: '_execute_current_command',
Suggestion: 'Execute Current Command',
},
];
}
expect(suggestions).toEqual([
...executeCommand,
{
Complete: 'jira issue create --project KT --summary',
Description: 'The Jira issue summary',
Hint: 'The thing is working great!',
IconData: 'Create icon',
Suggestion: '--summary',
},
{
Complete: 'jira issue create --project KT --verbose',
Description: 'display details',
Hint: 'yes or no!',
IconData: 'Create icon',
Suggestion: '--verbose',
},
{
Complete: 'jira issue create --project KT --epic',
Description: 'The Jira epic',
Hint: 'The thing is working great!',
IconData: 'Create icon',
Suggestion: '--epic',
},
]);
});
test('create flags mid-flag', async () => {
const mid = await parser.getSuggestions('/jira issue create --project KT --summ');
expect(mid).toEqual([
{
Complete: 'jira issue create --project KT --summary',
Description: 'The Jira issue summary',
Hint: 'The thing is working great!',
IconData: 'Create icon',
Suggestion: '--summary',
},
]);
const full = await parser.getSuggestions('/jira issue create --project KT --summary');
expect(full).toEqual([
{
Complete: 'jira issue create --project KT --summary',
Description: 'The Jira issue summary',
Hint: 'The thing is working great!',
IconData: 'Create icon',
Suggestion: '--summary',
},
]);
});
test('empty text value suggestion', async () => {
const suggestions = await parser.getSuggestions('/jira issue create --project KT --summary ');
expect(suggestions).toEqual([
{
Complete: 'jira issue create --project KT --summary',
Description: 'The Jira issue summary',
Hint: '',
IconData: 'Create icon',
Suggestion: '',
},
]);
});
test('partial text value suggestion', async () => {
const suggestions = await parser.getSuggestions('/jira issue create --project KT --summary Sum');
expect(suggestions).toEqual([
{
Complete: 'jira issue create --project KT --summary Sum',
Description: 'The Jira issue summary',
Hint: '',
IconData: 'Create icon',
Suggestion: 'Sum',
},
]);
});
test('quote text value suggestion close quotes', async () => {
const suggestions = await parser.getSuggestions('/jira issue create --project KT --summary "Sum');
expect(suggestions).toEqual([
{
Complete: 'jira issue create --project KT --summary "Sum"',
Description: 'The Jira issue summary',
Hint: '',
IconData: 'Create icon',
Suggestion: 'Sum',
},
]);
});
test('tick text value suggestion close quotes', async () => {
const suggestions = await parser.getSuggestions('/jira issue create --project KT --summary `Sum');
expect(suggestions).toEqual([
{
Complete: 'jira issue create --project KT --summary `Sum`',
Description: 'The Jira issue summary',
Hint: '',
IconData: 'Create icon',
Suggestion: 'Sum',
},
]);
});
test('create flag summary value', async () => {
const suggestions = await parser.getSuggestions('/jira issue create --summary ');
expect(suggestions).toEqual([
{
Complete: 'jira issue create --summary',
Description: 'The Jira issue summary',
Hint: '',
IconData: 'Create icon',
Suggestion: '',
},
]);
});
test('create flag project dynamic select value', async () => {
const f = Client4.executeAppCall;
Client4.executeAppCall = jest.fn().mockResolvedValue(Promise.resolve({type: AppCallResponseTypes.OK, data: {items: [{label: 'special-label', value: 'special-value'}]}}));
const suggestions = await parser.getSuggestions('/jira issue create --project ');
Client4.executeAppCall = f;
expect(suggestions).toEqual([
{
Complete: 'jira issue create --project special-value',
Suggestion: 'special-value',
Description: 'special-label',
Hint: '',
IconData: 'Create icon',
},
]);
});
test('create flag epic static select value', async () => {
let suggestions = await parser.getSuggestions('/jira issue create --project KT --summary "great feature" --epic ');
expect(suggestions).toEqual([
{
Complete: 'jira issue create --project KT --summary "great feature" --epic epic1',
Suggestion: 'Dylan Epic',
Description: 'The Jira epic',
Hint: 'The thing is working great!',
IconData: 'Create icon',
},
{
Complete: 'jira issue create --project KT --summary "great feature" --epic epic2',
Suggestion: 'Michael Epic',
Description: 'The Jira epic',
Hint: 'The thing is working great!',
IconData: 'Create icon',
},
]);
suggestions = await parser.getSuggestions('/jira issue create --project KT --summary "great feature" --epic M');
expect(suggestions).toEqual([
{
Complete: 'jira issue create --project KT --summary "great feature" --epic epic2',
Suggestion: 'Michael Epic',
Description: 'The Jira epic',
Hint: 'The thing is working great!',
IconData: 'Create icon',
},
]);
suggestions = await parser.getSuggestions('/jira issue create --project KT --summary "great feature" --epic Nope');
expect(suggestions).toEqual([
{
Complete: 'jira issue create --project KT --summary "great feature" --epic',
Suggestion: '',
Description: 'No matching options.',
Hint: '',
IconData: '',
},
]);
});
test('filled out form shows execute', async () => {
const suggestions = await parser.getSuggestions('/jira issue create --project KT --summary "great feature" --epic epicvalue --verbose true ');
if (!checkForExecuteSuggestion) {
expect(suggestions).toEqual([]);
return;
}
expect(suggestions).toEqual([
{
Complete: 'jira issue create --project KT --summary "great feature" --epic epicvalue --verbose true _execute_current_command',
Suggestion: 'Execute Current Command',
Description: 'Select this option or use Ctrl+Enter to execute the current command.',
IconData: '_execute_current_command',
Hint: '',
},
]);
});
});
describe('composeCallFromCommand', () => {
const base = {
context: {
app_id: 'jira',
channel_id: 'current_channel_id',
location: '/command',
root_id: 'root_id',
team_id: 'team_id',
},
path: '/create-issue',
};
test('empty form', async () => {
const cmd = '/jira issue create';
const values = {};
const call = await parser.composeCallFromCommand(cmd);
expect(call).toEqual({
...base,
raw_command: cmd,
expand: {},
query: undefined,
selected_field: undefined,
values,
});
});
test('full form', async () => {
const cmd = '/jira issue create --summary "Here it is" --epic epic1 --verbose true --project';
const values = {
summary: 'Here it is',
epic: {
label: 'Dylan Epic',
value: 'epic1',
},
verbose: 'true',
project: '',
};
const call = await parser.composeCallFromCommand(cmd);
expect(call).toEqual({
...base,
expand: {},
selected_field: undefined,
query: undefined,
raw_command: cmd,
values,
});
});
test('dynamic lookup test', async () => {
const f = Client4.executeAppCall;
const mockedExecute = jest.fn().mockResolvedValue(Promise.resolve({type: AppCallResponseTypes.OK, data: {items: [{label: 'special-label', value: 'special-value'}]}}));
Client4.executeAppCall = mockedExecute;
const suggestions = await parser.getSuggestions('/jira issue create --summary "The summary" --epic epic1 --project special');
Client4.executeAppCall = f;
expect(suggestions).toEqual([
{
Complete: 'jira issue create --summary "The summary" --epic epic1 --project special-value',
Suggestion: 'special-value',
Description: 'special-label',
Hint: '',
IconData: 'Create icon',
},
]);
expect(mockedExecute).toHaveBeenCalledWith({
context: {
app_id: 'jira',
channel_id: 'current_channel_id',
location: '/command',
root_id: 'root_id',
team_id: 'team_id',
},
expand: {},
path: '/create-issue',
query: 'special',
raw_command: '/jira issue create --summary "The summary" --epic epic1 --project special',
selected_field: 'project',
values: {
summary: 'The summary',
epic: {
label: 'Dylan Epic',
value: 'epic1',
},
},
}, AppCallTypes.LOOKUP);
});
});
});

View file

@ -0,0 +1,78 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type {
AppCallRequest,
AppBinding,
AppField,
AppSelectOption,
AppCallResponse,
AppCallValues,
AppContext,
AppForm,
AutocompleteElement,
AutocompleteDynamicSelect,
AutocompleteStaticSelect,
AutocompleteUserSelect,
AutocompleteChannelSelect,
} from '@mm-redux/types/apps';
import type {
AutocompleteSuggestion,
} from '@mm-redux/types/integrations';
export type {AutocompleteSuggestion};
export type {
Channel,
} from '@mm-redux/types/channels';
export type {
GlobalState,
} from '@mm-redux/types/store';
export type {
DispatchFunc,
} from '@mm-redux/types/actions';
export {
AppBindingLocations,
AppCallTypes,
AppFieldTypes,
AppCallResponseTypes,
} from '@mm-redux/constants/apps';
export {getAppsBindings} from '@mm-redux/selectors/entities/apps';
export {getPost} from '@mm-redux/selectors/entities/posts';
export {getChannel, getCurrentChannel, getChannelByName as selectChannelByName} from '@mm-redux/selectors/entities/channels';
export {getCurrentTeamId, getCurrentTeam} from '@mm-redux/selectors/entities/teams';
export {getUserByUsername as selectUserByUsername} from '@mm-redux/selectors/entities/users';
export {getUserByUsername} from '@mm-redux/actions/users';
export {getChannelByNameAndTeamName} from '@mm-redux/actions/channels';
export {sendEphemeralPost} from '@actions/views/post';
export {doAppCall} from '@actions/apps';
export {createCallRequest} from '@utils/apps';
import Store from '@store/store';
export const getStore = () => Store.redux;
import keyMirror from '@mm-redux/utils/key_mirror';
export {keyMirror};
export const EXECUTE_CURRENT_COMMAND_ITEM_ID = '_execute_current_command';
import type {ParsedCommand} from './app_command_parser';
export const getExecuteSuggestion = (_: ParsedCommand): AutocompleteSuggestion | null => { // eslint-disable-line @typescript-eslint/no-unused-vars
return null;
};
import {Alert} from 'react-native';
import {intlShape} from 'react-intl';
export const displayError = (intl: typeof intlShape, body: string) => {
const title = intl.formatMessage({
id: 'mobile.general.error.title',
defaultMessage: 'Error',
});
Alert.alert(title, body);
};

View file

@ -0,0 +1,228 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {
AppBinding,
AppForm,
AppFieldTypes,
} from './app_command_parser_test_dependencies';
export const reduxTestState = {
entities: {
channels: {
currentChannelId: 'current_channel_id',
myMembers: {
current_channel_id: {
channel_id: 'current_channel_id',
user_id: 'current_user_id',
roles: 'channel_role',
mention_count: 1,
msg_count: 9,
},
},
channels: {
current_channel_id: {
id: 'current_channel_id',
name: 'default-name',
display_name: 'Default',
delete_at: 0,
type: 'O',
total_msg_count: 10,
team_id: 'team_id',
},
current_user_id__existingId: {
id: 'current_user_id__existingId',
name: 'current_user_id__existingId',
display_name: 'Default',
delete_at: 0,
type: '0',
total_msg_count: 0,
team_id: 'team_id',
},
},
channelsInTeam: {
'team-id': ['current_channel_id'],
},
},
teams: {
currentTeamId: 'team-id',
teams: {
'team-id': {
id: 'team_id',
name: 'team-1',
displayName: 'Team 1',
},
},
myMembers: {
'team-id': {roles: 'team_role'},
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_role'},
},
},
preferences: {
myPreferences: {
'display_settings--name_format': {
category: 'display_settings',
name: 'name_format',
user_id: 'current_user_id',
value: 'username',
},
},
},
roles: {
roles: {
system_role: {
permissions: [],
},
team_role: {
permissions: [],
},
channel_role: {
permissions: [],
},
},
},
general: {
license: {IsLicensed: 'false'},
serverVersion: '5.25.0',
config: {PostEditTimeLimit: -1},
},
},
};
export const viewCommand: AppBinding = {
app_id: 'jira',
label: 'view',
location: 'view',
description: 'View details of a Jira issue',
form: {
call: {
path: '/view-issue',
},
fields: [
{
name: 'project',
label: 'project',
description: 'The Jira project description',
type: AppFieldTypes.DYNAMIC_SELECT,
hint: 'The Jira project hint',
is_required: true,
},
{
name: 'issue',
position: 1,
description: 'The Jira issue key',
type: AppFieldTypes.TEXT,
hint: 'MM-11343',
is_required: true,
},
],
} as AppForm,
};
export const createCommand: AppBinding = {
app_id: 'jira',
label: 'create',
location: 'create',
description: 'Create a new Jira issue',
icon: 'Create icon',
hint: 'Create hint',
form: {
call: {
path: '/create-issue',
},
fields: [
{
name: 'project',
label: 'project',
description: 'The Jira project description',
type: AppFieldTypes.DYNAMIC_SELECT,
hint: 'The Jira project hint',
},
{
name: 'summary',
label: 'summary',
description: 'The Jira issue summary',
type: AppFieldTypes.TEXT,
hint: 'The thing is working great!',
},
{
name: 'verbose',
label: 'verbose',
description: 'display details',
type: AppFieldTypes.BOOL,
hint: 'yes or no!',
},
{
name: 'epic',
label: 'epic',
description: 'The Jira epic',
type: AppFieldTypes.STATIC_SELECT,
hint: 'The thing is working great!',
options: [
{
label: 'Dylan Epic',
value: 'epic1',
},
{
label: 'Michael Epic',
value: 'epic2',
},
],
},
],
} as AppForm,
};
export const testBindings: AppBinding[] = [
{
app_id: '',
label: '',
location: '/command',
bindings: [
{
app_id: 'jira',
label: 'jira',
description: 'Interact with your Jira instance',
icon: 'Jira icon',
hint: 'Jira hint',
bindings: [{
app_id: 'jira',
label: 'issue',
description: 'Interact with Jira issues',
icon: 'Issue icon',
hint: 'Issue hint',
bindings: [
viewCommand,
createCommand,
],
}],
},
{
app_id: 'other',
label: 'other',
description: 'Other description',
icon: 'Other icon',
hint: 'Other hint',
bindings: [{
app_id: 'other',
label: 'sub1',
description: 'Some Description',
form: {
fields: [{
name: 'fieldname',
label: 'fieldlabel',
description: 'field description',
type: AppFieldTypes.TEXT,
hint: 'field hint',
}],
},
}],
},
],
},
];

View file

@ -0,0 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import thunk from 'redux-thunk';
export {thunk};
const configureStore = require('redux-mock-store').default;
export {configureStore};
export {Client4} from '@mm-redux/client';
export type {AppBinding, AppForm} from '@mm-redux/types/apps';
export {AppFieldTypes} from '@mm-redux/constants/apps';
export const checkForExecuteSuggestion = false;

View file

@ -1,15 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {bindActionCreators} from 'redux';
import {bindActionCreators, Dispatch} from 'redux';
import {connect} from 'react-redux';
import {createSelector} from 'reselect';
import {GlobalState} from '@mm-redux/types/store';
import {getAutocompleteCommands, getCommandAutocompleteSuggestions} from '@mm-redux/actions/integrations';
import {getAutocompleteCommandsList, getCommandAutocompleteSuggestionsList} from '@mm-redux/selectors/entities/integrations';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {appsEnabled} from '@utils/apps';
import SlashSuggestion from './slash_suggestion';
// TODO: Remove when all below commands have been implemented
@ -25,16 +28,17 @@ const mobileCommandsSelector = createSelector(
},
);
function mapStateToProps(state) {
function mapStateToProps(state: GlobalState) {
return {
commands: mobileCommandsSelector(state),
currentTeamId: getCurrentTeamId(state),
theme: getTheme(state),
suggestions: getCommandAutocompleteSuggestionsList(state),
appsEnabled: appsEnabled(state),
};
}
function mapDispatchToProps(dispatch) {
function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
getAutocompleteCommands,

View file

@ -0,0 +1,263 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import Preferences from '@mm-redux/constants/preferences';
import {Command, AutocompleteSuggestion} from '@mm-redux/types/integrations';
import Store from '@store/store';
import {
thunk,
configureStore,
Client4,
AppBinding,
} from './app_command_parser/tests/app_command_parser_test_dependencies';
import {
reduxTestState,
testBindings,
} from './app_command_parser/tests/app_command_parser_test_data';
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 './slash_suggestion';
describe('components/autocomplete/slash_suggestion', () => {
const sampleCommand = {
trigger: 'jitsi',
auto_complete: true,
auto_complete_desc: 'The Jitsi Description',
auto_complete_hint: 'The Jitsi Hint',
display_name: 'The Jitsi Display Name',
icon_url: 'Jitsi icon',
} as Command;
const baseProps: Props = {
actions: {
getAutocompleteCommands: jest.fn(),
getCommandAutocompleteSuggestions: jest.fn(),
},
currentTeamId: '',
commands: [sampleCommand],
isSearch: false,
maxListHeight: 50,
theme: Preferences.THEMES.default,
onChangeText: jest.fn(),
onResultCountChange: jest.fn(),
value: '',
nestedScrollEnabled: false,
suggestions: [],
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}/>);
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 show commands from props.commands', async () => {
const command = {
trigger: 'thetrigger',
auto_complete: true,
auto_complete_desc: 'The Description',
auto_complete_hint: 'The Hint',
display_name: 'The Display Name',
icon_url: 'iconurl.com',
} as Command;
const props: Props = {
...baseProps,
commands: [command],
};
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
wrapper.setProps({value: '/the'});
expect(wrapper.state('dataSource')).toEqual([
{
Complete: 'thetrigger',
Description: 'The Description',
Hint: 'The Hint',
IconData: 'iconurl.com',
Suggestion: '/thetrigger',
},
]);
});
test('should show commands from app base commands', async () => {
const props: Props = {
...baseProps,
commands: [],
};
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
wrapper.setProps({value: '/ji'});
expect(wrapper.state('dataSource')).toEqual([
{
Complete: 'jira',
Description: 'Interact with your Jira instance',
Hint: 'Jira hint',
IconData: 'Jira icon',
Suggestion: '/jira',
},
]);
});
test('should show commands from app base commands and regular commands', async () => {
const props: Props = {
...baseProps,
};
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
wrapper.setProps({value: '/'});
expect(wrapper.state('dataSource')).toEqual([
{
Complete: 'jira',
Description: 'Interact with your Jira instance',
Hint: 'Jira hint',
IconData: 'Jira icon',
Suggestion: '/jira',
},
{
Complete: 'jitsi',
Description: 'The Jitsi Description',
Hint: 'The Jitsi Hint',
IconData: 'Jitsi icon',
Suggestion: '/jitsi',
},
{
Complete: 'other',
Description: 'Other description',
Hint: 'Other hint',
IconData: 'Other icon',
Suggestion: '/other',
},
]);
wrapper.setProps({value: '/ji'});
expect(wrapper.state('dataSource')).toEqual([
{
Complete: 'jira',
Description: 'Interact with your Jira instance',
Hint: 'Jira hint',
IconData: 'Jira icon',
Suggestion: '/jira',
},
{
Complete: 'jitsi',
Description: 'The Jitsi Description',
Hint: 'The Jitsi Hint',
IconData: 'Jitsi icon',
Suggestion: '/jitsi',
},
]);
});
test('should show commands from app sub commands', async (done) => {
const props: Props = {
...baseProps,
};
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
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,
appsEnabled: false,
};
const wrapper = shallow<SlashSuggestion>(<SlashSuggestion {...props}/>);
wrapper.setProps({value: '/', suggestions: []});
expect(wrapper.state('dataSource')).toEqual([
{
Complete: 'jitsi',
Description: 'The Jitsi Description',
Hint: 'The Jitsi Hint',
IconData: 'Jitsi icon',
Suggestion: '/jitsi',
},
]);
wrapper.setProps({value: '/ji', suggestions: []});
expect(wrapper.state('dataSource')).toEqual([
{
Complete: 'jitsi',
Description: 'The Jitsi Description',
Hint: 'The Jitsi Hint',
IconData: 'Jitsi icon',
Suggestion: '/jitsi',
},
]);
wrapper.setProps({value: '/jira i', suggestions: []});
expect(wrapper.state('dataSource')).toEqual([]);
});
});

View file

@ -1,8 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {intlShape} from 'react-intl';
import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import {
FlatList,
Platform,
@ -11,52 +11,73 @@ import {
import {analytics} from '@init/analytics.ts';
import {Client4} from '@mm-redux/client';
import {isMinimumServerVersion} from '@mm-redux/utils/helpers';
import {Command, AutocompleteSuggestion, CommandArgs} from '@mm-redux/types/integrations';
import {Theme} from '@mm-redux/types/preferences';
import {makeStyleSheetFromTheme} from '@utils/theme';
import SlashSuggestionItem from './slash_suggestion_item';
import {AppCommandParser} from './app_command_parser/app_command_parser';
const TIME_BEFORE_NEXT_COMMAND_REQUEST = 1000 * 60 * 5;
export default class SlashSuggestion extends PureComponent {
static propTypes = {
actions: PropTypes.shape({
getAutocompleteCommands: PropTypes.func.isRequired,
getCommandAutocompleteSuggestions: PropTypes.func.isRequired,
}).isRequired,
currentTeamId: PropTypes.string.isRequired,
commands: PropTypes.array,
isSearch: PropTypes.bool,
maxListHeight: PropTypes.number,
theme: PropTypes.object.isRequired,
onChangeText: PropTypes.func.isRequired,
onResultCountChange: PropTypes.func.isRequired,
value: PropTypes.string,
nestedScrollEnabled: PropTypes.bool,
suggestions: PropTypes.array,
rootId: PropTypes.string,
channelId: PropTypes.string,
export type Props = {
actions: {
getAutocompleteCommands: (channelID: string) => void;
getCommandAutocompleteSuggestions: (value: string, teamID: string, args: CommandArgs) => void;
};
currentTeamId: string;
commands: Command[];
isSearch?: boolean;
maxListHeight?: number;
theme: Theme;
onChangeText: (text: string) => void;
onResultCountChange: (count: number) => void;
value: string;
nestedScrollEnabled?: boolean;
suggestions: AutocompleteSuggestion[];
rootId?: string;
channelId: string;
appsEnabled: boolean;
};
type State = {
active: boolean;
dataSource: AutocompleteSuggestion[];
lastCommandRequest: number;
}
export default class SlashSuggestion extends PureComponent<Props, State> {
static defaultProps = {
defaultChannel: {},
value: '',
};
static contextTypes = {
intl: intlShape.isRequired,
};
appCommandParser: AppCommandParser;
state = {
active: false,
dataSource: [],
lastCommandRequest: 0,
};
setActive(active) {
constructor(props: Props, context: any) {
super(props);
this.appCommandParser = new AppCommandParser(null, context.intl, props.channelId, props.rootId);
}
setActive(active: boolean) {
this.setState({active});
}
setLastCommandRequest(lastCommandRequest) {
setLastCommandRequest(lastCommandRequest: number) {
this.setState({lastCommandRequest});
}
componentDidUpdate(prevProps) {
componentDidUpdate(prevProps: Props) {
if ((this.props.value === prevProps.value && this.props.suggestions === prevProps.suggestions && this.props.commands === prevProps.commands) ||
this.props.isSearch || this.props.value.startsWith('//') || !this.props.channelId) {
return;
@ -88,25 +109,23 @@ export default class SlashSuggestion extends PureComponent {
this.setLastCommandRequest(Date.now());
}
const matches = this.filterSlashSuggestions(nextValue.substring(1), nextCommands);
this.updateSuggestions(matches);
this.showBaseCommands(nextValue, nextCommands, prevProps.channelId, prevProps.rootId);
} else if (isMinimumServerVersion(Client4.getServerVersion(), 5, 24)) {
if (nextSuggestions === prevProps.suggestions) {
// 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) {
const args = {
channel_id: prevProps.channelId,
team_id: prevProps.currentTeamId,
...(prevProps.rootId && {root_id: prevProps.rootId, parent_id: prevProps.rootId}),
};
this.props.actions.getCommandAutocompleteSuggestions(nextValue, nextTeamId, args);
} else {
const matches = [];
nextSuggestions.forEach((sug) => {
if (!this.contains(matches, '/' + sug.Complete)) {
matches.push({
Complete: sug.Complete,
Suggestion: sug.Suggestion,
Hint: sug.Hint,
Description: sug.Description,
});
const matches: AutocompleteSuggestion[] = [];
nextSuggestions.forEach((suggestion: AutocompleteSuggestion) => {
if (!this.contains(matches, '/' + suggestion.Complete)) {
matches.push(suggestion);
}
});
this.updateSuggestions(matches);
@ -116,15 +135,52 @@ export default class SlashSuggestion extends PureComponent {
}
}
updateSuggestions = (matches) => {
showBaseCommands = (text: string, commands: Command[], channelID: string, rootID?: string) => {
let matches: AutocompleteSuggestion[] = [];
if (this.props.appsEnabled) {
const appCommands = this.getAppBaseCommandSuggestions(text, channelID, rootID);
matches = matches.concat(appCommands);
}
matches = matches.concat(this.filterCommands(text.substring(1), commands));
matches.sort((match1, match2) => {
if (match1.Suggestion === match2.Suggestion) {
return 0;
}
return match1.Suggestion > match2.Suggestion ? 1 : -1;
});
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);
const suggestions = this.appCommandParser.getSuggestionsBase(pretext);
return suggestions;
}
updateSuggestions = (matches: AutocompleteSuggestion[]) => {
this.setState({
active: matches.length,
active: Boolean(matches.length),
dataSource: matches,
});
this.props.onResultCountChange(matches.length);
}
filterSlashSuggestions = (matchTerm, commands) => {
filterCommands = (matchTerm: string, commands: Command[]): AutocompleteSuggestion[] => {
const data = commands.filter((command) => {
if (!command.auto_complete) {
return false;
@ -140,15 +196,16 @@ export default class SlashSuggestion extends PureComponent {
Suggestion: '/' + item.trigger,
Hint: item.auto_complete_hint,
Description: item.auto_complete_desc,
IconData: item.icon_url,
};
});
}
contains = (matches, complete) => {
return matches.findIndex((match) => match.complete === complete) !== -1;
contains = (matches: AutocompleteSuggestion[], complete: string): boolean => {
return matches.findIndex((match) => match.Complete === complete) !== -1;
}
completeSuggestion = (command) => {
completeSuggestion = (command: string) => {
const {onChangeText} = this.props;
analytics.trackCommand('complete_suggestion', `/${command} `);
@ -176,9 +233,9 @@ export default class SlashSuggestion extends PureComponent {
}
};
keyExtractor = (item) => item.id || item.Suggestion;
keyExtractor = (item: Command & AutocompleteSuggestion): string => item.id || item.Suggestion;
renderItem = ({item}) => (
renderItem = ({item}: {item: AutocompleteSuggestion}) => (
<SlashSuggestionItem
description={item.Description}
hint={item.Hint}
@ -209,15 +266,13 @@ export default class SlashSuggestion extends PureComponent {
data={this.state.dataSource}
keyExtractor={this.keyExtractor}
renderItem={this.renderItem}
pageSize={10}
initialListSize={10}
nestedScrollEnabled={nestedScrollEnabled}
/>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
listView: {
flex: 1,

View file

@ -2,15 +2,16 @@
// See LICENSE.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {Image, Text, View} from 'react-native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import slashIcon from '@assets/images/autocomplete/slash_command.png';
import {Theme} from '@mm-redux/types/preferences';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
const slashIcon = require('@assets/images/autocomplete/slash_command.png');
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
icon: {
fontSize: 24,
@ -48,7 +49,16 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
};
});
const SlashSuggestionItem = (props) => {
type Props = {
complete: string;
description: string;
hint: string;
onPress: (complete: string) => void;
suggestion: string;
theme: Theme;
}
const SlashSuggestionItem = (props: Props) => {
const insets = useSafeAreaInsets();
const {
complete,
@ -70,6 +80,14 @@ const SlashSuggestionItem = (props) => {
suggestionText = suggestionText.substring(1);
}
if (hint) {
if (suggestionText.length) {
suggestionText += ` ${hint}`;
} else {
suggestionText = hint;
}
}
return (
<TouchableWithFeedback
onPress={completeSuggestion}
@ -87,7 +105,7 @@ const SlashSuggestionItem = (props) => {
/>
</View>
<View style={style.suggestionContainer}>
<Text style={style.suggestionName}>{`${suggestionText} ${hint}`}</Text>
<Text style={style.suggestionName}>{`${suggestionText}`}</Text>
<Text
ellipsizeMode='tail'
numberOfLines={1}
@ -101,13 +119,4 @@ const SlashSuggestionItem = (props) => {
);
};
SlashSuggestionItem.propTypes = {
description: PropTypes.string,
hint: PropTypes.string,
onPress: PropTypes.func.isRequired,
theme: PropTypes.object.isRequired,
suggestion: PropTypes.string,
complete: PropTypes.string,
};
export default SlashSuggestionItem;

View file

@ -0,0 +1,149 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import Button from 'react-native-button';
import {intlShape} from 'react-intl';
import {preventDoubleTap} from 'app/utils/tap';
import {makeStyleSheetFromTheme, changeOpacity} from 'app/utils/theme';
import {getStatusColors} from '@utils/message_attachment_colors';
import ButtonBindingText from './button_binding_text';
import {Theme} from '@mm-redux/types/preferences';
import {ActionResult} from '@mm-redux/types/actions';
import {AppBinding, AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps';
import {Post} from '@mm-redux/types/posts';
import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {createCallContext, createCallRequest} from '@utils/apps';
import {Channel} from '@mm-redux/types/channels';
type Props = {
actions: {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
getChannel: (channelId: string) => Promise<ActionResult>;
sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise<ActionResult>;
};
post: Post;
binding: AppBinding;
theme: Theme;
currentTeamID: string;
}
export default class ButtonBinding extends PureComponent<Props> {
static contextTypes = {
intl: intlShape.isRequired,
};
handleActionPress = preventDoubleTap(async () => {
const ephemeral = (message: string) => this.props.actions.sendEphemeralPost(message, this.props.post.channel_id, this.props.post.root_id);
const {
binding,
post,
currentTeamID,
} = this.props;
const intl = this.context.intl;
if (!binding.call) {
return;
}
let teamID = '';
const {data} = await this.props.actions.getChannel(post.channel_id) as {data?: any; error?: any};
if (data) {
const channel = data as Channel;
teamID = channel.team_id;
}
const context = createCallContext(
binding.app_id,
AppBindingLocations.IN_POST + binding.location,
post.channel_id,
teamID || currentTeamID,
post.id,
);
const call = createCallRequest(
binding.call,
context,
{post: AppExpandLevels.EXPAND_ALL},
);
this.setState({executing: true});
const res = await this.props.actions.doAppCall(call, AppCallTypes.SUBMIT, this.context.intl);
this.setState({executing: false});
if (res.error) {
const errorResponse = res.error;
const errorMessage = errorResponse.error || intl.formatMessage(
{id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
ephemeral(errorMessage);
return;
}
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
ephemeral(callResp.markdown);
}
return;
case AppCallResponseTypes.NAVIGATE:
case AppCallResponseTypes.FORM:
return;
default: {
const errorMessage = intl.formatMessage(
{
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
},
{
type: callResp.type,
},
);
ephemeral(errorMessage);
}
}
}, 4000);
render() {
const {theme, binding} = this.props;
const style = getStyleSheet(theme);
return (
<Button
containerStyle={[style.button]}
disabledContainerStyle={style.buttonDisabled}
onPress={this.handleActionPress}
>
<ButtonBindingText
message={binding.label}
style={style.text}
/>
</Button>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const STATUS_COLORS = getStatusColors(theme);
return {
button: {
borderRadius: 4,
borderColor: changeOpacity(STATUS_COLORS.default, 0.25),
borderWidth: 2,
opacity: 1,
alignItems: 'center',
marginTop: 12,
justifyContent: 'center',
height: 36,
},
buttonDisabled: {
backgroundColor: changeOpacity(theme.buttonBg, 0.3),
},
text: {
color: STATUS_COLORS.default,
fontSize: 15,
fontWeight: '600',
lineHeight: 17,
},
};
});

View file

@ -0,0 +1,143 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import ButtonBindingText from './button_binding_text';
describe('ButtonBinding emojis', () => {
const emojis = [
{
name: 'smile',
literal: ':smile:',
},
{
name: 'custom_emoji',
literal: ':custom_emoji:',
},
{
name: 'heart',
literal: ':heart:',
},
{
name: 'one',
literal: ':one:',
},
{
name: 'slightly_smiling_face',
literal: ':)',
},
{
name: 'wink',
literal: ';)',
},
{
name: 'open_mouth',
literal: ':o',
},
{
name: 'scream',
literal: ':-o',
},
{
name: 'smirk',
literal: ':]',
},
{
name: 'smile',
literal: ':D',
},
{
name: 'stuck_out_tongue_closed_eyes',
literal: 'x-d',
},
{
name: 'stuck_out_tongue',
literal: ':p',
},
{
name: 'rage',
literal: ':@',
},
{
name: 'slightly_frowning_face',
literal: ':(',
},
{
name: 'cry',
literal: ':`(',
},
{
name: 'confused',
literal: ':/',
},
{
name: 'confounded',
literal: ':s',
},
{
name: 'neutral_face',
literal: ':|',
},
{
name: 'flushed',
literal: ':$',
},
{
name: 'mask',
literal: ':-x',
},
{
name: 'heart',
literal: '<3',
},
{
name: 'broken_heart',
literal: '</3',
},
{
name: '+1',
literal: ':+1:',
},
{
name: '-1',
literal: ':-1:',
},
];
emojis.forEach(({name, literal}) => {
test('only emoji ' + name, () => {
const baseProps = {message: literal, style: {fontSize: 12}};
const wrapper = shallow(<ButtonBindingText {...baseProps}/>);
expect(wrapper.getElement().props.children.length).toBe(1);
const child = wrapper.getElement().props.children[0];
expect(child.type.displayName).toBe('Connect(Emoji)');
expect(child.props.emojiName).toBe(name);
expect(child.props.literal).toBe(literal);
expect(child.props.textStyle.fontSize).toBe(12);
});
test('emoji ' + name + ' with additional text', () => {
const baseProps = {message: 'emoji test with literal equals to ' + literal, style: {fontSize: 12}};
const wrapper = shallow(<ButtonBindingText {...baseProps}/>);
expect(wrapper.getElement().props.children.length).toBe(2);
const textChild = wrapper.getElement().props.children[0];
const emoticonChild = wrapper.getElement().props.children[1];
expect(textChild.type.displayName).toBe('Text');
expect(textChild.props.children).toBe('emoji test with literal equals to ');
expect(textChild.props.style.fontSize).toBe(12);
expect(emoticonChild.props.emojiName).toBe(name);
expect(emoticonChild.props.literal).toBe(literal);
expect(emoticonChild.type.displayName).toBe('Connect(Emoji)');
expect(emoticonChild.props.textStyle.fontSize).toBe(12);
});
});
});

View file

@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import PropTypes from 'prop-types';
import {Text, View, StyleSheet, StyleProp, TextStyle} from 'react-native';
import Emoji from 'app/components/emoji';
import {getEmoticonName} from 'app/utils/emoji_utils';
import {reEmoji, reEmoticon, reMain} from 'app/constants/emoji';
export default function ButtonBindingText({message, style}: {message: string; style: StyleProp<TextStyle>}) {
const components = [] as JSX.Element[];
let text = message;
while (text) {
let match;
// See if the text starts with an emoji
if ((match = text.match(reEmoji))) {
components.push(
<Emoji
key={components.length}
literal={match[0]}
emojiName={match[1]}
textStyle={style}
/>,
);
text = text.substring(match[0].length);
continue;
}
// Or an emoticon
if ((match = text.match(reEmoticon))) {
const emoticonName = getEmoticonName(match[0]);
if (emoticonName) {
components.push(
<Emoji
key={components.length}
literal={match[0]}
emojiName={emoticonName}
textStyle={style}
/>,
);
text = text.substring(match[0].length);
continue;
}
}
// This is plain text, so capture as much text as possible until we hit the next possible emoji. Note that
// reMain always captures at least one character, so text will always be getting shorter
match = text.match(reMain);
if (!match) {
continue;
}
components.push(
<Text
key={components.length}
style={style}
>
{match[0]}
</Text>,
);
text = text.substring(match[0].length);
}
return (
<View style={styles.container}>
{components}
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'flex-start',
flexDirection: 'row',
flexWrap: 'wrap',
},
});
ButtonBindingText.propTypes = {
message: PropTypes.string.isRequired,
style: PropTypes.object.isRequired,
};

View file

@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {connect} from 'react-redux';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {GlobalState} from '@mm-redux/types/store';
import {ActionFunc, ActionResult, GenericAction} from '@mm-redux/types/actions';
import {AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps';
import {doAppCall} from '@actions/apps';
import {getPost} from '@mm-redux/selectors/entities/posts';
import ButtonBinding from './button_binding';
import {getChannel} from '@mm-redux/actions/channels';
import {sendEphemeralPost} from '@actions/views/post';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
type OwnProps = {
postId: string;
}
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
return {
theme: getTheme(state),
post: getPost(state, ownProps.postId),
currentTeamID: getCurrentTeamId(state),
};
}
type Actions = {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
getChannel: (channelId: string) => Promise<ActionResult>;
sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise<ActionResult>;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
doAppCall,
getChannel,
sendEphemeralPost,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(ButtonBinding);

View file

@ -0,0 +1,115 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import {LayoutChangeEvent, ScrollView, StyleProp, TextStyle, View, ViewStyle} from 'react-native';
import Markdown from 'app/components/markdown';
import ShowMoreButton from 'app/components/show_more_button';
import {Theme} from '@mm-redux/types/preferences';
const SHOW_MORE_HEIGHT = 60;
type Props = {
baseTextStyle: StyleProp<TextStyle>,
blockStyles?: StyleProp<ViewStyle>[],
deviceHeight: number,
onPermalinkPress?: () => void,
textStyles?: StyleProp<TextStyle>[],
theme?: Theme,
value?: string,
}
type State = {
collapsed: boolean;
isLongText: boolean;
maxHeight?: number;
}
export default class EmbedText extends PureComponent<Props, State> {
static getDerivedStateFromProps(nextProps: Props, prevState: State) {
const {deviceHeight} = nextProps;
const maxHeight = Math.round((deviceHeight * 0.4) + SHOW_MORE_HEIGHT);
if (maxHeight !== prevState.maxHeight) {
return {
maxHeight,
};
}
return null;
}
constructor(props: Props) {
super(props);
this.state = {
collapsed: true,
isLongText: false,
};
}
handleLayout = (event: LayoutChangeEvent) => {
const {height} = event.nativeEvent.layout;
const {maxHeight} = this.state;
if (height >= (maxHeight || 0)) {
this.setState({
isLongText: true,
});
}
};
toggleCollapseState = () => {
const {collapsed} = this.state;
this.setState({collapsed: !collapsed});
};
render() {
const {
baseTextStyle,
blockStyles,
onPermalinkPress,
textStyles,
theme,
value,
} = this.props;
const {collapsed, isLongText, maxHeight} = this.state;
if (!value) {
return null;
}
return (
<View>
<ScrollView
style={{maxHeight: (collapsed ? maxHeight : undefined), overflow: 'hidden'}}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
showsHorizontalScrollIndicator={false}
>
<View
onLayout={this.handleLayout}
removeClippedSubviews={isLongText && collapsed}
>
<Markdown
baseTextStyle={baseTextStyle}
textStyles={textStyles}
blockStyles={blockStyles}
disableGallery={true}
value={value}
onPermalinkPress={onPermalinkPress}
/>
</View>
</ScrollView>
{isLongText &&
<ShowMoreButton
onPress={this.toggleCollapseState}
showMore={collapsed}
theme={theme}
/>
}
</View>
);
}
}

View file

@ -0,0 +1,69 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import {View} from 'react-native';
import Markdown from '@components/markdown';
import {makeStyleSheetFromTheme} from '@utils/theme';
import {Theme} from '@mm-redux/types/preferences';
type Props = {
theme: Theme;
value?: string;
}
export default class EmbedTitle extends PureComponent<Props> {
render() {
const {
value,
theme,
} = this.props;
if (!value) {
return null;
}
const style = getStyleSheet(theme);
const title = (
<Markdown
disableHashtags={true}
disableAtMentions={true}
disableChannelLink={true}
disableGallery={true}
autolinkedUrlSchemes={[]}
mentionKeys={[]}
theme={theme}
value={value}
baseTextStyle={style.title}
textStyles={{link: style.link}}
/>
);
return (
<View style={style.container}>
{title}
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
marginTop: 3,
flex: 1,
flexDirection: 'row',
},
title: {
color: theme.centerChannelColor,
fontWeight: '600',
marginBottom: 5,
fontSize: 14,
lineHeight: 20,
},
link: {
color: theme.linkColor,
},
};
});

View file

@ -0,0 +1,85 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, TextStyle, View, ViewStyle} from 'react-native';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import EmbedSubBindings from './embedded_sub_bindings';
import EmbedText from './embed_text';
import EmbedTitle from './embed_title';
import {Theme} from '@mm-redux/types/preferences';
import {AppBinding} from '@mm-redux/types/apps';
import {copyAndFillBindings} from '@utils/apps';
type Props = {
embed: AppBinding,
baseTextStyle?: StyleProp<TextStyle>,
blockStyles?: StyleProp<ViewStyle>[],
deviceHeight: number,
postId: string,
onPermalinkPress?: () => void,
theme: Theme,
textStyles?: StyleProp<TextStyle>[],
}
export default function EmbeddedBinding(props: Props) {
const {
embed,
baseTextStyle,
blockStyles,
deviceHeight,
onPermalinkPress,
postId,
textStyles,
theme,
} = props;
const style = getStyleSheet(theme);
const bindings = copyAndFillBindings(embed)?.bindings;
return (
<>
<View style={[style.container, style.border]}>
<EmbedTitle
theme={theme}
value={embed.label}
/>
<EmbedText
baseTextStyle={baseTextStyle}
blockStyles={blockStyles}
deviceHeight={deviceHeight}
onPermalinkPress={onPermalinkPress}
textStyles={textStyles}
value={embed.description}
theme={theme}
/>
<EmbedSubBindings
bindings={bindings}
postId={postId}
/>
</View>
</>
);
}
const getStyleSheet = makeStyleSheetFromTheme((theme:Theme) => {
return {
container: {
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.15),
borderRightColor: changeOpacity(theme.centerChannelColor, 0.15),
borderTopColor: changeOpacity(theme.centerChannelColor, 0.15),
borderBottomWidth: 1,
borderRightWidth: 1,
borderTopWidth: 1,
marginTop: 5,
padding: 12,
},
border: {
borderLeftColor: changeOpacity(theme.linkColor, 0.6),
borderLeftWidth: 3,
},
};
});

View file

@ -0,0 +1,52 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import BindingMenu from './menu_binding';
import ButtonBinding from './button_binding';
import {AppBinding} from '@mm-redux/types/apps';
type Props = {
bindings?: AppBinding[];
postId: string;
}
export default function EmbeddedSubBindings(props: Props) {
const {
bindings,
postId,
} = props;
if (!bindings?.length) {
return null;
}
const content = [] as JSX.Element[];
bindings.forEach((binding) => {
if (!binding.app_id || !binding.call) {
return;
}
if ((binding.bindings?.length || 0) > 0) {
content.push(
<BindingMenu
key={binding.location}
binding={binding}
postId={postId}
/>,
);
return;
}
content.push(
<ButtonBinding
key={binding.location}
binding={binding}
postId={postId}
/>,
);
});
return content.length ? (<>{content}</>) : null;
}

View file

@ -0,0 +1,57 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {StyleProp, TextStyle, View, ViewStyle} from 'react-native';
import EmbeddedBinding from './embedded_binding';
import {Theme} from '@mm-redux/types/preferences';
import {AppBinding} from '@mm-redux/types/apps';
type Props = {
embeds: AppBinding[],
baseTextStyle?: StyleProp<TextStyle>,
blockStyles?: StyleProp<ViewStyle>[],
deviceHeight: number,
deviceWidth: number,
postId: string,
onPermalinkPress?: () => void,
theme: Theme,
textStyles?: StyleProp<TextStyle>[],
}
export default function EmbeddedBindings(props: Props) {
const {
embeds,
baseTextStyle,
blockStyles,
deviceHeight,
onPermalinkPress,
postId,
theme,
textStyles,
} = props;
const content = [] as JSX.Element[];
embeds.forEach((embed, i) => {
content.push(
<EmbeddedBinding
embed={embed}
baseTextStyle={baseTextStyle}
blockStyles={blockStyles}
deviceHeight={deviceHeight}
key={'att_' + i}
onPermalinkPress={onPermalinkPress}
postId={postId}
theme={theme}
textStyles={textStyles}
/>,
);
});
return (
<View style={{flex: 1, flexDirection: 'column'}}>
{content}
</View>
);
}

View file

@ -0,0 +1,45 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {connect} from 'react-redux';
import {GlobalState} from '@mm-redux/types/store';
import {doAppCall} from '@actions/apps';
import {ActionFunc, ActionResult, GenericAction} from '@mm-redux/types/actions';
import {AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps';
import {getPost} from '@mm-redux/selectors/entities/posts';
import MenuBinding from './menu_binding';
import {getChannel} from '@mm-redux/actions/channels';
import {sendEphemeralPost} from '@actions/views/post';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
type OwnProps = {
postId: string;
}
function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
return {
post: getPost(state, ownProps.postId),
currentTeamID: getCurrentTeamId(state),
};
}
type Actions = {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
getChannel: (channelId: string) => Promise<ActionResult>;
sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise<ActionResult>;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
doAppCall,
getChannel,
sendEphemeralPost,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(MenuBinding);

View file

@ -0,0 +1,137 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import AutocompleteSelector from 'app/components/autocomplete_selector';
import {intlShape} from 'react-intl';
import {PostActionOption} from '@mm-redux/types/integration_actions';
import {Post} from '@mm-redux/types/posts';
import {AppBinding, AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps';
import {ActionResult} from '@mm-redux/types/actions';
import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {Channel} from '@mm-redux/types/channels';
import {createCallContext, createCallRequest} from '@utils/apps';
type Props = {
actions: {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
getChannel: (channelId: string) => Promise<ActionResult>;
sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise<ActionResult>;
};
binding?: AppBinding;
post: Post;
currentTeamID: string;
}
type State = {
selected?: PostActionOption;
}
export default class MenuBinding extends PureComponent<Props, State> {
static contextTypes = {
intl: intlShape.isRequired,
};
constructor(props: Props) {
super(props);
this.state = {};
}
handleSelect = async (selected?: PostActionOption) => {
if (!selected) {
return;
}
const ephemeral = (message: string) => this.props.actions.sendEphemeralPost(message, this.props.post.channel_id, this.props.post.root_id);
this.setState({selected});
const binding = this.props.binding?.bindings?.find((b) => b.location === selected.value);
if (!binding) {
console.debug('Trying to select element not present in binding.'); //eslint-disable-line no-console
return;
}
if (!binding.call) {
return;
}
const {
actions,
post,
currentTeamID,
} = this.props;
const intl = this.context.intl;
let teamID = '';
const {data} = await this.props.actions.getChannel(post.channel_id) as {data?: any; error?: any};
if (data) {
const channel = data as Channel;
teamID = channel.team_id;
}
const context = createCallContext(
binding.app_id,
AppBindingLocations.IN_POST + binding.location,
post.channel_id,
teamID || currentTeamID,
post.id,
);
const call = createCallRequest(
binding.call,
context,
{post: AppExpandLevels.EXPAND_ALL},
);
const res = await actions.doAppCall(call, AppCallTypes.SUBMIT, this.context.intl);
if (res.error) {
const errorResponse = res.error;
const errorMessage = errorResponse.error || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
ephemeral(errorMessage);
return;
}
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
ephemeral(callResp.markdown);
}
return;
case AppCallResponseTypes.NAVIGATE:
case AppCallResponseTypes.FORM:
return;
default: {
const errorMessage = intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
});
ephemeral(errorMessage);
}
}
};
render() {
const {
binding,
} = this.props;
const {selected} = this.state;
const options = binding?.bindings?.map<PostActionOption>((b:AppBinding) => {
return {text: b.label, value: b.location || ''};
});
return (
<AutocompleteSelector
placeholder={binding?.label}
options={options}
selected={selected}
onSelected={this.handleSelect}
/>
);
}
}

View file

@ -6,16 +6,7 @@ import PropTypes from 'prop-types';
import {Text, View, StyleSheet} from 'react-native';
import Emoji from 'app/components/emoji';
import {getEmoticonName} from 'app/utils/emoji_utils';
// reEmoji matches an emoji (eg. :taco:) at the start of a string.
const reEmoji = /^:([a-z0-9_\-+]+):\B/i;
// reEmoticon matches an emoticon (eg. :D) at the start of a string.
const reEmoticon = /^(?:(:-?\))|(;-?\))|(:o)|(:-o)|(:-?])|(:-?d)|(x-d)|(:-?p)|(:-?[[@])|(:-?\()|(:[']-?\()|(:-?\/)|(:-?s)|(:-?\|)|(:-?\$)|(:-x)|(<3|&lt;3)|(<\/3|&lt;\/3)|(:[`']-?\(|:&#x27;\(|:&#39;\())(?=$|\s|[*_~?])/i;
// reMain matches some amount of plain text, starting at the beginning of the string and hopefully stopping right
// before the next emoji by looking for any character that could start an emoji (:, ;, x, or <)
const reMain = /^[\s\S]+?(?=[:;x<]|$)/i;
import {reEmoji, reEmoticon, reMain} from 'app/constants/emoji';
export default function ActionButtonText({message, style}) {
const components = [];

View file

@ -27,6 +27,7 @@ import {getDimensions} from 'app/selectors/device';
import {hasEmojisOnly} from 'app/utils/emoji_utils';
import PostBody from './post_body';
import {appsEnabled} from '@utils/apps';
const POST_TIMEOUT = 20000;
@ -112,6 +113,7 @@ export function makeMapStateToProps() {
theme: getTheme(state),
mentionKeys: getMentionKeysForPost(state, channel, postProps?.disable_group_highlight, postProps?.mentionHighlightDisabled),
canDelete,
appsEnabled: appsEnabled(state),
...getDimensions(state),
};
};

View file

@ -73,6 +73,7 @@ export default class PostBody extends PureComponent {
theme: PropTypes.object,
location: PropTypes.string,
mentionKeys: PropTypes.array,
appsEnabled: PropTypes.bool.isRequired,
};
static defaultProps = {
@ -271,13 +272,14 @@ export default class PostBody extends PureComponent {
onPermalinkPress,
post,
postProps,
appsEnabled,
} = this.props;
if (isSystemMessage && !isPostEphemeral) {
return null;
}
if (!metadata?.embeds?.length) {
if (!metadata?.embeds?.length && !(appsEnabled && postProps.app_bindings)) {
return null;
}

View file

@ -46,6 +46,7 @@ describe('PostBody', () => {
isEmojiOnly: false,
shouldRenderJumboEmoji: false,
theme: Preferences.THEMES.default,
appsEnabled: false,
};
test('should mount additional content for non-system messages', () => {

View file

@ -14,6 +14,7 @@ import {ViewTypes} from 'app/constants';
import {getDimensions} from 'app/selectors/device';
import PostBodyAdditionalContent from './post_body_additional_content';
import {appsEnabled} from '@utils/apps';
function selectOpenGraphData(metadata, url) {
if (!metadata || !metadata.embeds) {
@ -27,7 +28,7 @@ function selectOpenGraphData(metadata, url) {
function mapStateToProps(state, ownProps) {
const config = getConfig(state);
const link = ownProps.metadata.embeds[0]?.url || '';
const link = ownProps.metadata?.embeds?.[0]?.url || '';
let expandedLink;
if (link) {
expandedLink = selectExpandedLink(state, link);
@ -54,6 +55,7 @@ function mapStateToProps(state, ownProps) {
openGraphData,
showLinkPreviews: previewsEnabled && config.EnableLinkPreviews === 'true' && !removeLinkPreview,
theme: getTheme(state),
appsEnabled: appsEnabled(state),
};
}

View file

@ -23,6 +23,7 @@ import EventEmitter from '@mm-redux/utils/event_emitter';
import {generateId} from '@utils/file';
import {calculateDimensions, getViewPortWidth, openGalleryAtIndex} from '@utils/images';
import {getYouTubeVideoId, isImageLink, isYoutubeLink, tryOpenURL} from '@utils/url';
import EmbeddedBindings from '@components/embedded_bindings';
const MAX_YOUTUBE_IMAGE_HEIGHT = 202;
const MAX_YOUTUBE_IMAGE_WIDTH = 360;
@ -53,6 +54,7 @@ export default class PostBodyAdditionalContent extends ImageViewPort {
showLinkPreviews: PropTypes.bool.isRequired,
theme: PropTypes.object.isRequired,
textStyles: PropTypes.object,
appsEnabled: PropTypes.bool.isRequired,
};
static contextTypes = {
@ -377,8 +379,41 @@ export default class PostBodyAdditionalContent extends ImageViewPort {
return null;
};
renderAppEmbeds = () => {
const {
postId,
postProps,
baseTextStyle,
blockStyles,
deviceHeight,
deviceWidth,
onPermalinkPress,
textStyles,
theme,
} = this.props;
const {app_bindings} = postProps;
if (app_bindings && app_bindings.length) {
return (
<EmbeddedBindings
embed={app_bindings}
baseTextStyle={baseTextStyle}
blockStyles={blockStyles}
deviceHeight={deviceHeight}
deviceWidth={deviceWidth}
postId={postId}
onPermalinkPress={onPermalinkPress}
theme={theme}
textStyles={textStyles}
/>
);
}
return null;
}
renderOpenGraph = (isYouTube, isImage) => {
const {isReplyPost, link, metadata, openGraphData, postId, showLinkPreviews, theme} = this.props;
const {isReplyPost, link, metadata, openGraphData, postId, showLinkPreviews, theme, appsEnabled} = this.props;
if (isYouTube || (isImage && !openGraphData)) {
return null;
@ -389,6 +424,13 @@ export default class PostBodyAdditionalContent extends ImageViewPort {
return attachments;
}
if (appsEnabled) {
const appEmbeds = this.renderAppEmbeds();
if (appEmbeds) {
return appEmbeds;
}
}
if (!openGraphData || !showLinkPreviews) {
return null;
}
@ -495,9 +537,10 @@ export default class PostBodyAdditionalContent extends ImageViewPort {
if (expandedLink) {
link = expandedLink;
}
const {attachments} = postProps;
if (!link && !attachments) {
const {attachments, app_bindings, appsEnabled} = postProps;
if (!link && !attachments && !(appsEnabled && app_bindings)) {
return null;
}

View file

@ -23,6 +23,7 @@ describe('PostBodyAdditionalContent', () => {
postProps: {},
showLinkPreviews: false,
theme: Preferences.THEMES.default,
appsEnabled: false,
};
test('should call getRedirectLocation only if expandedLink has not been set', () => {

View file

@ -303,7 +303,7 @@ export default class DraftInput extends PureComponent {
return;
}
const {data, error} = await executeCommand(msg, channelId, rootId);
const {data, error} = await executeCommand(msg, channelId, rootId, intl);
this.setState({sendingMessage: false});
if (error) {

View file

@ -18,6 +18,8 @@ import {
} from '@utils/theme';
export default class TextSetting extends PureComponent {
static validTypes = ['input', 'textarea', 'number', 'email', 'tel', 'url', 'password'];
static propTypes = {
id: PropTypes.string.isRequired,
label: PropTypes.oneOfType([

View file

@ -3,3 +3,13 @@
export const ALL_EMOJIS = 'all_emojis';
export const MAX_ALLOWED_REACTIONS = 40;
// reEmoji matches an emoji (eg. :taco:) at the start of a string.
export const reEmoji = /^:([a-z0-9_\-+]+):\B/i;
// reEmoticon matches an emoticon (eg. :D) at the start of a string.
export const reEmoticon = /^(?:(:-?\))|(;-?\))|(:o)|(:-o)|(:-?])|(:-?d)|(x-d)|(:-?p)|(:-?[[@])|(:-?\()|(:[']-?\()|(:-?\/)|(:-?s)|(:-?\|)|(:-?\$)|(:-x)|(<3|&lt;3)|(<\/3|&lt;\/3)|(:[`']-?\(|:&#x27;\(|:&#39;\())(?=$|\s|[*_~?])/i;
// reMain matches some amount of plain text, starting at the beginning of the string and hopefully stopping right
// before the next emoji by looking for any character that could start an emoji (:, ;, x, or <)
export const reMain = /^[\s\S]+?(?=[:;x<]|$)/i;

View file

@ -2,3 +2,6 @@
// See LICENSE.txt for license information.
export const THREAD = 'thread';
export const PERMALINK = 'permalink';
export const SEARCH = 'search';
export const CHANNEL = 'channel';

View file

@ -43,5 +43,6 @@ const WebsocketEvents = {
INCREASE_POST_VISIBILITY_BY_ONE: 'increase_post_visibility_by_one',
MEMBERROLE_UPDATED: 'memberrole_updated',
RECEIVED_GROUP: 'received_group',
APPS_FRAMEWORK_REFRESH_BINDINGS: 'custom_com.mattermost.apps_refresh_bindings',
};
export default WebsocketEvents;

View file

@ -0,0 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import keyMirror from '@mm-redux/utils/key_mirror';
export default keyMirror({
RECEIVED_APP_BINDINGS: null,
});

View file

@ -20,6 +20,7 @@ import GroupTypes from './groups';
import BotTypes from './bots';
import PluginTypes from './plugins';
import ChannelCategoryTypes from './channel_categories';
import AppsTypes from './apps';
export {
ErrorTypes,
@ -41,4 +42,5 @@ export {
BotTypes,
PluginTypes,
ChannelCategoryTypes,
AppsTypes,
};

View file

@ -0,0 +1,15 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AppsTypes} from '@mm-redux/action_types';
import {Client4} from '@mm-redux/client';
import {ActionFunc} from '@mm-redux/types/actions';
import {bindClientFunc} from './helpers';
export function fetchAppBindings(userID: string, channelID: string): ActionFunc {
return bindClientFunc({
clientFunc: () => Client4.getAppsBindings(userID, channelID),
onSuccess: AppsTypes.RECEIVED_APP_BINDINGS,
});
}

View file

@ -111,7 +111,7 @@ export function bindClientFunc({
// Debounce function based on underscores modified to use es6 and a cb
export function debounce(func: (...args: any) => unknown, wait: number, immediate: boolean, cb: () => unknown) {
export function debounce(func: (...args: any) => unknown, wait: number, immediate?: boolean, cb?: () => unknown) {
let timeout: NodeJS.Timeout|null;
return function fx(...args: Array<any>) {
const runLater = () => {

View file

@ -13,7 +13,7 @@ import {analytics} from '@init/analytics.ts';
import {batchActions, DispatchFunc, GetStateFunc, ActionFunc} from '@mm-redux/types/actions';
import {Command, DialogSubmission, IncomingWebhook, OAuthApp, OutgoingWebhook} from '@mm-redux/types/integrations';
import {Command, CommandArgs, DialogSubmission, IncomingWebhook, OAuthApp, OutgoingWebhook} from '@mm-redux/types/integrations';
import {logError} from './errors';
import {bindClientFunc, forceLogoutIfNecessary, requestSuccess, requestFailure} from './helpers';
@ -189,7 +189,7 @@ export function getAutocompleteCommands(teamId: string, page = 0, perPage: numbe
});
}
export function getCommandAutocompleteSuggestions(userInput: string, teamId: string, commandArgs: any): ActionFunc {
export function getCommandAutocompleteSuggestions(userInput: string, teamId: string, commandArgs: CommandArgs): ActionFunc {
return async (dispatch: DispatchFunc) => {
let data: any = null;
try {
@ -236,7 +236,7 @@ export function editCommand(command: Command): ActionFunc {
});
}
export function executeCommand(command: Command, args: Array<string>): ActionFunc {
export function executeCommand(command: string, args: CommandArgs): ActionFunc {
return bindClientFunc({
clientFunc: Client4.executeCommand,
params: [
@ -457,7 +457,7 @@ export function handleGotoLocation(href: string, intl: any): ActionFunc {
break;
}
} else {
const {formatMessage} = this.context.intl;
const {formatMessage} = intl;
const onError = () => Alert.alert(
formatMessage({
id: 'mobile.server_link.error.title',

View file

@ -18,6 +18,7 @@ import {CustomEmoji} from '@mm-redux/types/emojis';
import {Config} from '@mm-redux/types/config';
import {Bot, BotPatch} from '@mm-redux/types/bots';
import {SyncablePatch} from '@mm-redux/types/groups';
import {AppCallRequest, AppCallType} from '@mm-redux/types/apps';
import fetch from './fetch_etag';
import {General} from '../constants';
@ -3043,6 +3044,35 @@ export default class Client4 {
);
};
// Apps
getAppsProxyRoute() {
return `${this.url}/plugins/com.mattermost.apps`;
}
executeAppCall = async (call: AppCallRequest, type: AppCallType) => {
const callCopy = {
...call,
path: `${call.path}/${type}`,
context: {
...call.context,
user_agent: 'mobile',
},
};
return this.doFetch(
`${this.getAppsProxyRoute()}/api/v1/call`,
{method: 'post', body: JSON.stringify(callCopy)},
);
}
getAppsBindings = async (userID: string, channelID: string) => {
return this.doFetch(
this.getAppsProxyRoute() + `/api/v1/bindings?user_id=${userID}&channel_id=${channelID}&user_agent_type=mobile`,
{method: 'get'},
);
}
// Client Helpers
doFetch = async (url: string, options: Options) => {

View file

@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AppCallResponseType, AppCallType, AppExpandLevel, AppFieldType} from '@mm-redux/types/apps';
export const AppBindingLocations = {
POST_MENU_ITEM: '/post_menu',
CHANNEL_HEADER_ICON: '/channel_header',
COMMAND: '/command',
IN_POST: '/in_post',
};
export const AppBindingPresentations = {
MODAL: 'modal',
};
export const AppCallResponseTypes: { [name: string]: AppCallResponseType } = {
OK: 'ok',
ERROR: 'error',
FORM: 'form',
CALL: 'call',
NAVIGATE: 'navigate',
};
export const AppCallTypes: { [name: string]: AppCallType } = {
SUBMIT: 'submit',
LOOKUP: 'lookup',
FORM: 'form',
CANCEL: 'cancel',
};
export const AppExpandLevels: { [name: string]: AppExpandLevel } = {
EXPAND_DEFAULT: '',
EXPAND_NONE: 'none',
EXPAND_ALL: 'all',
EXPAND_SUMMARY: 'summary',
};
export const AppFieldTypes: { [name: string]: AppFieldType } = {
TEXT: 'text',
STATIC_SELECT: 'static_select',
DYNAMIC_SELECT: 'dynamic_select',
BOOL: 'bool',
USER: 'user',
CHANNEL: 'channel',
};

View file

@ -0,0 +1,340 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`bindings Invalid channel header get filtered 1`] = `
Object {
"bindings": Array [
Object {
"app_id": "1",
"bindings": Array [
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"label": "a",
"location": "/post_menu/locA",
},
],
"call": Object {},
"location": "/post_menu",
},
Object {
"app_id": "2",
"bindings": Array [
Object {
"app_id": "2",
"bindings": undefined,
"call": Object {},
"label": "a",
"location": "/post_menu/locA",
},
],
"call": Object {},
"location": "/post_menu",
},
Object {
"app_id": "1",
"bindings": Array [
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"icon": "icon",
"label": "b",
"location": "/channel_header/locB",
},
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"label": "c",
"location": "/channel_header/locC",
},
],
"call": Object {},
"location": "/channel_header",
},
Object {
"app_id": "2",
"bindings": Array [
Object {
"app_id": "2",
"bindings": undefined,
"call": Object {},
"icon": "icon",
"label": "c",
"location": "/channel_header/locC",
},
],
"call": Object {},
"location": "/channel_header",
},
Object {
"app_id": "3",
"bindings": Array [
Object {
"app_id": "3",
"bindings": undefined,
"call": Object {},
"label": "c",
"location": "/channel_header/locC",
},
],
"call": Object {},
"location": "/channel_header",
},
Object {
"app_id": "3",
"bindings": Array [
Object {
"app_id": "3",
"bindings": undefined,
"call": Object {},
"label": "c",
"location": "/command/locC",
},
],
"call": Object {},
"location": "/command",
},
],
}
`;
exports[`bindings Invalid commands get filtered 1`] = `
Object {
"bindings": Array [
Object {
"app_id": "1",
"bindings": Array [
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"label": "a",
"location": "/post_menu/locA",
},
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"label": "a",
"location": "/post_menu/locB",
},
],
"call": Object {},
"location": "/post_menu",
},
Object {
"app_id": "1",
"bindings": Array [
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"icon": "icon",
"label": "b",
"location": "/channel_header/locB",
},
],
"call": Object {},
"location": "/channel_header",
},
Object {
"app_id": "3",
"bindings": Array [
Object {
"app_id": "3",
"bindings": Array [
Object {
"app_id": "3",
"bindings": undefined,
"call": Object {},
"label": "c2",
"location": "/command/locC/subC2",
},
],
"call": Object {},
"label": "c",
"location": "/command/locC",
},
],
"call": Object {},
"location": "/command",
},
Object {
"app_id": "1",
"bindings": Array [],
"location": "/command",
},
Object {
"app_id": "2",
"bindings": Array [
Object {
"app_id": "2",
"bindings": Array [
Object {
"app_id": "2",
"bindings": undefined,
"call": Object {},
"label": "c1",
"location": "/command/locC/subC1",
},
Object {
"app_id": "2",
"bindings": undefined,
"call": Object {},
"label": "c2",
"location": "/command/locC/subC2",
},
],
"call": Object {},
"label": "c",
"location": "/command/locC",
},
],
"call": Object {},
"location": "/command",
},
],
}
`;
exports[`bindings Invalid post menu get filtered 1`] = `
Object {
"bindings": Array [
Object {
"app_id": "1",
"bindings": Array [
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"label": "a",
"location": "/post_menu/locB",
},
],
"call": Object {},
"location": "/post_menu",
},
Object {
"app_id": "2",
"bindings": Array [
Object {
"app_id": "2",
"bindings": undefined,
"call": Object {},
"label": "a",
"location": "/post_menu/locA",
},
Object {
"app_id": "2",
"bindings": undefined,
"call": Object {},
"label": "b",
"location": "/post_menu/locB",
},
],
"call": Object {},
"location": "/post_menu",
},
Object {
"app_id": "3",
"bindings": Array [],
"location": "/post_menu",
},
Object {
"app_id": "1",
"bindings": Array [
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"icon": "icon",
"label": "b",
"location": "/channel_header/locB",
},
],
"call": Object {},
"location": "/channel_header",
},
Object {
"app_id": "3",
"bindings": Array [
Object {
"app_id": "3",
"bindings": undefined,
"call": Object {},
"label": "c",
"location": "/command/locC",
},
],
"call": Object {},
"location": "/command",
},
],
}
`;
exports[`bindings No element get filtered 1`] = `
Object {
"bindings": Array [
Object {
"app_id": "1",
"bindings": Array [
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"label": "a",
"location": "/post_menu/locA",
},
],
"call": Object {},
"location": "/post_menu",
},
Object {
"app_id": "2",
"bindings": Array [
Object {
"app_id": "2",
"bindings": undefined,
"call": Object {},
"label": "a",
"location": "/post_menu/locA",
},
],
"call": Object {},
"location": "/post_menu",
},
Object {
"app_id": "1",
"bindings": Array [
Object {
"app_id": "1",
"bindings": undefined,
"call": Object {},
"icon": "icon",
"label": "b",
"location": "/channel_header/locB",
},
],
"call": Object {},
"location": "/channel_header",
},
Object {
"app_id": "3",
"bindings": Array [
Object {
"app_id": "3",
"bindings": undefined,
"call": Object {},
"label": "c",
"location": "/command/locC",
},
],
"call": Object {},
"location": "/command",
},
],
}
`;

View file

@ -0,0 +1,360 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AppsTypes} from '@mm-redux/action_types';
import * as Reducers from './apps';
describe('bindings', () => {
const initialState = [];
test('No element get filtered', () => {
const data = {
bindings: [
{
app_id: '1',
location: '/post_menu',
bindings: [
{
location: 'locA',
label: 'a',
call: {},
},
],
},
{
app_id: '2',
location: '/post_menu',
bindings: [
{
location: 'locA',
label: 'a',
call: {},
},
],
},
{
app_id: '1',
location: '/channel_header',
bindings: [
{
location: 'locB',
label: 'b',
icon: 'icon',
call: {},
},
],
},
{
app_id: '3',
location: '/command',
bindings: [
{
location: 'locC',
label: 'c',
call: {},
},
],
},
],
};
const state = Reducers.bindings(
initialState,
{
type: AppsTypes.RECEIVED_APP_BINDINGS,
data,
},
);
expect(state).toMatchSnapshot();
});
test('Invalid channel header get filtered', () => {
const data = {
bindings: [
{
app_id: '1',
location: '/post_menu',
bindings: [
{
location: 'locA',
label: 'a',
call: {},
},
],
},
{
app_id: '2',
location: '/post_menu',
bindings: [
{
location: 'locA',
label: 'a',
call: {},
},
],
},
{
app_id: '1',
location: '/channel_header',
bindings: [
{
location: 'locB',
label: 'b',
icon: 'icon',
call: {},
},
{
location: 'locC',
label: 'c',
call: {},
},
],
},
{
app_id: '2',
location: '/channel_header',
bindings: [
{
location: 'locB',
icon: 'icon',
call: {},
},
{
location: 'locC',
label: 'c',
icon: 'icon',
call: {},
},
],
},
{
app_id: '3',
location: '/channel_header',
bindings: [
{
location: 'locB',
call: {},
},
{
location: 'locC',
label: 'c',
call: {},
},
],
},
{
app_id: '3',
location: '/command',
bindings: [
{
location: 'locC',
label: 'c',
call: {},
},
],
},
],
};
const state = Reducers.bindings(
initialState,
{
type: AppsTypes.RECEIVED_APP_BINDINGS,
data,
},
);
expect(state).toMatchSnapshot();
});
test('Invalid post menu get filtered', () => {
const data = {
bindings: [
{
app_id: '1',
location: '/post_menu',
bindings: [
{
location: 'locA',
call: {},
},
{
location: 'locB',
label: 'a',
call: {},
},
],
},
{
app_id: '2',
location: '/post_menu',
bindings: [
{
location: 'locA',
label: 'a',
call: {},
},
{
location: 'locB',
label: 'b',
call: {},
},
],
},
{
app_id: '3',
location: '/post_menu',
bindings: [
{
location: 'locA',
call: {},
},
],
},
{
app_id: '1',
location: '/channel_header',
bindings: [
{
location: 'locB',
label: 'b',
icon: 'icon',
call: {},
},
],
},
{
app_id: '3',
location: '/command',
bindings: [
{
location: 'locC',
label: 'c',
call: {},
},
],
},
],
};
const state = Reducers.bindings(
initialState,
{
type: AppsTypes.RECEIVED_APP_BINDINGS,
data,
},
);
expect(state).toMatchSnapshot();
});
test('Invalid commands get filtered', () => {
const data = {
bindings: [
{
app_id: '1',
location: '/post_menu',
bindings: [
{
location: 'locA',
label: 'a',
call: {},
},
{
location: 'locB',
label: 'a',
call: {},
},
],
},
{
app_id: '1',
location: '/channel_header',
bindings: [
{
location: 'locB',
label: 'b',
icon: 'icon',
call: {},
},
],
},
{
app_id: '3',
location: '/command',
bindings: [
{
location: 'locC',
label: 'c',
bindings: [
{
location: 'subC1',
call: {},
},
{
location: 'subC2',
label: 'c2',
call: {},
},
],
},
{
location: 'locD',
label: 'd',
bindings: [
{
location: 'subC1',
call: {},
},
],
},
],
},
{
app_id: '1',
location: '/command',
bindings: [
{
location: 'locC',
call: {},
},
],
},
{
app_id: '2',
location: '/command',
bindings: [
{
location: 'locC',
label: 'c',
call: {},
bindings: [
{
location: 'subC1',
label: 'c1',
call: {},
},
{
location: 'subC2',
label: 'c2',
call: {},
},
],
},
],
},
],
};
const state = Reducers.bindings(
initialState,
{
type: AppsTypes.RECEIVED_APP_BINDINGS,
data,
},
);
expect(state).toMatchSnapshot();
});
});

View file

@ -0,0 +1,23 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {combineReducers} from 'redux';
import {AppsTypes} from '@mm-redux/action_types';
import {AppBinding, AppsState} from '@mm-redux/types/apps';
import {GenericAction} from '@mm-redux/types/actions';
import {validateBindings} from '@utils/apps';
export function bindings(state: AppBinding[] = [], action: GenericAction): AppBinding[] {
switch (action.type) {
case AppsTypes.RECEIVED_APP_BINDINGS: {
validateBindings(action.data);
return action.data || [];
}
default:
return state;
}
}
export default (combineReducers({
bindings,
}) as (b: AppsState, a: GenericAction) => AppsState);

View file

@ -21,6 +21,7 @@ import schemes from './schemes';
import groups from './groups';
import bots from './bots';
import channelCategories from './channel_categories';
import apps from './apps';
export default combineReducers({
general,
@ -41,4 +42,5 @@ export default combineReducers({
groups,
bots,
channelCategories,
apps,
});

View file

@ -0,0 +1,16 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {GlobalState} from '@mm-redux/types/store';
import {AppBinding} from '@mm-redux/types/apps';
export function getAppsBindings(state: GlobalState, location?: string): AppBinding[] {
if (!state.entities.apps.bindings) {
return [];
}
if (location) {
const bindings = state.entities.apps.bindings.filter((b) => b.location === location);
return bindings.reduce((accum: AppBinding[], current: AppBinding) => accum.concat(current.bindings || []), []);
}
return state.entities.apps.bindings;
}

205
app/mm-redux/types/apps.ts Normal file
View file

@ -0,0 +1,205 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type AppManifest = {
app_id: string;
display_name: string;
description?: string;
homepage_url?: string;
root_url: string;
}
export type AppModalState = {
form: AppForm;
call: AppCallRequest;
}
export type AppsState = {
bindings: AppBinding[];
};
export type AppBinding = {
app_id: string;
location?: string;
icon?: string;
// Label is the (usually short) primary text to display at the location.
// - For LocationPostMenu is the menu item text.
// - For LocationChannelHeader is the dropdown text.
// - For LocationCommand is the name of the command
label: string;
// Hint is the secondary text to display
// - LocationPostMenu: not used
// - LocationChannelHeader: tooltip
// - LocationCommand: the "Hint" line
hint?: string;
// Description is the (optional) extended help text, used in modals and autocomplete
description?: string;
role_id?: string;
depends_on_team?: boolean;
depends_on_channel?: boolean;
depends_on_user?: boolean;
depends_on_post?: boolean;
// A Binding is either to a Call, or is a "container" for other locations -
// i.e. menu sub-items or subcommands.
call?: AppCall;
bindings?: AppBinding[];
form?: AppForm;
};
export type AppCallValues = {
[name: string]: any;
};
export type AppCallType = string;
export type AppCall = {
path: string;
expand?: AppExpand;
state?: any;
};
export type AppCallRequest = AppCall & {
context: AppContext;
values?: AppCallValues;
raw_command?: string;
selected_field?: string;
query?: string;
};
export type AppCallResponseType = string;
export type AppCallResponse<Res = unknown> = {
type: AppCallResponseType;
markdown?: string;
data?: Res;
error?: string;
navigate_to_url?: string;
use_external_browser?: boolean;
call?: AppCall;
form?: AppForm;
};
export type AppContext = {
app_id: string;
location?: string;
acting_user_id?: string;
user_id?: string;
channel_id?: string;
team_id?: string;
post_id?: string;
root_id?: string;
props?: AppContextProps;
user_agent?: string;
};
export type AppContextProps = {
[name: string]: string;
};
export type AppExpandLevel = string;
export type AppExpand = {
app?: AppExpandLevel;
acting_user?: AppExpandLevel;
channel?: AppExpandLevel;
config?: AppExpandLevel;
mentioned?: AppExpandLevel;
parent_post?: AppExpandLevel;
post?: AppExpandLevel;
root_post?: AppExpandLevel;
team?: AppExpandLevel;
user?: AppExpandLevel;
};
export type AppForm = {
title?: string;
header?: string;
footer?: string;
icon?: string;
submit_buttons?: string;
cancel_button?: boolean;
submit_on_cancel?: boolean;
fields: AppField[];
call?: AppCall;
depends_on?: string[];
};
export type AppFormValue = string | AppSelectOption | boolean | null;
export type AppFormValues = {[name: string]: AppFormValue};
export type AppSelectOption = {
label: string;
value: string;
icon_data?: string;
};
export type AppFieldType = string;
// This should go in mattermost-redux
export type AppField = {
// Name is the name of the JSON field to use.
name: string;
type: AppFieldType;
is_required?: boolean;
readonly?: boolean;
// Present (default) value of the field
value?: AppFormValue;
description?: string;
label?: string;
hint?: string;
position?: number;
modal_label?: string;
// Select props
refresh?: boolean;
options?: AppSelectOption[];
multiselect?: boolean;
// Text props
subtype?: string;
min_length?: number;
max_length?: number;
};
export type AutocompleteSuggestion = {
suggestion: string;
complete?: string;
description?: string;
hint?: string;
iconData?: string;
}
export type AutocompleteSuggestionWithComplete = AutocompleteSuggestion & {
complete: string;
}
export type AutocompleteElement = AppField;
export type AutocompleteStaticSelect = AutocompleteElement & {
options: AppSelectOption[];
};
export type AutocompleteDynamicSelect = AutocompleteElement;
export type AutocompleteUserSelect = AutocompleteElement;
export type AutocompleteChannelSelect = AutocompleteElement;
export type FormResponseData = {
errors?: {
[field: string]: string;
};
}
export type AppLookupResponse = {
items: AppSelectOption[];
}

View file

@ -0,0 +1,30 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export type PostAction = {
id?: string;
type?: string;
name?: string;
disabled?: boolean;
style?: string;
data_source?: string;
options?: PostActionOption[];
default_option?: string;
integration?: PostActionIntegration;
cookie?: string;
};
export type PostActionOption = {
text: string;
value: string;
};
export type PostActionIntegration = {
url?: string;
context?: Record<string, any>;
}
export type PostActionResponse = {
status: string;
trigger_id: string;
};

View file

@ -111,3 +111,24 @@ export type DialogElement = {
value: any;
}>;
};
export type InteractiveDialogConfig = {
app_id: string;
trigger_id: string;
url: string;
dialog: {
callback_id: string;
title: string;
introduction_text: string;
icon_url?: string;
elements: DialogElement[];
submit_label: string;
notify_on_cancel: boolean;
state: string;
};
};
export type CommandArgs = {
channel_id: string;
team_id: string;
root_id?: string;
parent_id?: string;
}

View file

@ -20,6 +20,7 @@ import {PreferenceType} from './preferences';
import {Bot} from './bots';
import {ChannelCategoriesState} from './channel_categories';
import {Dictionary} from './utilities';
import {AppsState} from './apps';
export type GlobalState = {
entities: {
@ -52,6 +53,7 @@ export type GlobalState = {
gifs: any;
groups: GroupsState;
channelCategories: ChannelCategoriesState;
apps: AppsState;
};
errors: Array<any>;
requests: {

View file

@ -0,0 +1,626 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SelectorScreen should match snapshot for channels 1`] = `
<RNCSafeAreaView
style={
Object {
"flex": 1,
}
}
>
<Connect(StatusBar) />
<View
style={
Object {
"height": 38,
"marginVertical": 5,
"paddingLeft": 8,
}
}
testID="selector.screen"
>
<Search
autoCapitalize="none"
backArrowSize={24}
backgroundColor="transparent"
blurOnSubmit={false}
cancelTitle="Cancel"
containerHeight={40}
deleteIconSize={20}
editable={true}
inputHeight={33}
inputStyle={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"color": "#3d3c40",
"fontSize": 15,
}
}
keyboardAppearance="light"
keyboardShouldPersist={false}
keyboardType="default"
onBlur={[Function]}
onCancelButtonPress={[Function]}
onChangeText={[Function]}
onSearchButtonPress={[Function]}
onSelectionChange={[Function]}
placeholder="Search"
placeholderTextColor="rgba(61,60,64,0.5)"
returnKeyType="search"
searchBarRightMargin={0}
searchIconSize={24}
showArrow={false}
showCancel={true}
testID="selector.search_bar"
tintColorDelete="rgba(61,60,64,0.5)"
tintColorSearch="rgba(61,60,64,0.5)"
titleCancelColor="#3d3c40"
value=""
/>
</View>
<CustomList
canRefresh={true}
data={Array []}
listType="flat"
loading={false}
loadingComponent={null}
noResults={null}
onLoadMore={[Function]}
onRowPress={[Function]}
renderItem={[Function]}
shouldRenderSeparator={true}
showNoResults={true}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</RNCSafeAreaView>
`;
exports[`SelectorScreen should match snapshot for channels 2`] = `
<RNCSafeAreaView
style={
Object {
"flex": 1,
}
}
>
<Connect(StatusBar) />
<View
style={
Object {
"height": 38,
"marginVertical": 5,
"paddingLeft": 8,
}
}
testID="selector.screen"
>
<Search
autoCapitalize="none"
backArrowSize={24}
backgroundColor="transparent"
blurOnSubmit={false}
cancelTitle="Cancel"
containerHeight={40}
deleteIconSize={20}
editable={true}
inputHeight={33}
inputStyle={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"color": "#3d3c40",
"fontSize": 15,
}
}
keyboardAppearance="light"
keyboardShouldPersist={false}
keyboardType="default"
onBlur={[Function]}
onCancelButtonPress={[Function]}
onChangeText={[Function]}
onSearchButtonPress={[Function]}
onSelectionChange={[Function]}
placeholder="Search"
placeholderTextColor="rgba(61,60,64,0.5)"
returnKeyType="search"
searchBarRightMargin={0}
searchIconSize={24}
showArrow={false}
showCancel={true}
testID="selector.search_bar"
tintColorDelete="rgba(61,60,64,0.5)"
tintColorSearch="rgba(61,60,64,0.5)"
titleCancelColor="#3d3c40"
value=""
/>
</View>
<CustomList
canRefresh={true}
data={Array []}
listType="flat"
loading={false}
loadingComponent={null}
noResults={null}
onLoadMore={[Function]}
onRowPress={[Function]}
renderItem={[Function]}
shouldRenderSeparator={true}
showNoResults={true}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</RNCSafeAreaView>
`;
exports[`SelectorScreen should match snapshot for explicit options 1`] = `
<RNCSafeAreaView
style={
Object {
"flex": 1,
}
}
>
<Connect(StatusBar) />
<View
style={
Object {
"height": 38,
"marginVertical": 5,
"paddingLeft": 8,
}
}
testID="selector.screen"
>
<Search
autoCapitalize="none"
backArrowSize={24}
backgroundColor="transparent"
blurOnSubmit={false}
cancelTitle="Cancel"
containerHeight={40}
deleteIconSize={20}
editable={true}
inputHeight={33}
inputStyle={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"color": "#3d3c40",
"fontSize": 15,
}
}
keyboardAppearance="light"
keyboardShouldPersist={false}
keyboardType="default"
onBlur={[Function]}
onCancelButtonPress={[Function]}
onChangeText={[Function]}
onSearchButtonPress={[Function]}
onSelectionChange={[Function]}
placeholder="Search"
placeholderTextColor="rgba(61,60,64,0.5)"
returnKeyType="search"
searchBarRightMargin={0}
searchIconSize={24}
showArrow={false}
showCancel={true}
testID="selector.search_bar"
tintColorDelete="rgba(61,60,64,0.5)"
tintColorSearch="rgba(61,60,64,0.5)"
titleCancelColor="#3d3c40"
value=""
/>
</View>
<CustomList
canRefresh={true}
data={
Array [
Object {
"label": "text",
"value": "value",
},
]
}
listType="flat"
loading={false}
loadingComponent={null}
noResults={null}
onLoadMore={[Function]}
onRowPress={[Function]}
renderItem={[Function]}
shouldRenderSeparator={true}
showNoResults={true}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</RNCSafeAreaView>
`;
exports[`SelectorScreen should match snapshot for searching 1`] = `
<RNCSafeAreaView
style={
Object {
"flex": 1,
}
}
>
<Connect(StatusBar) />
<View
style={
Object {
"height": 38,
"marginVertical": 5,
"paddingLeft": 8,
}
}
testID="selector.screen"
>
<Search
autoCapitalize="none"
backArrowSize={24}
backgroundColor="transparent"
blurOnSubmit={false}
cancelTitle="Cancel"
containerHeight={40}
deleteIconSize={20}
editable={true}
inputHeight={33}
inputStyle={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"color": "#3d3c40",
"fontSize": 15,
}
}
keyboardAppearance="light"
keyboardShouldPersist={false}
keyboardType="default"
onBlur={[Function]}
onCancelButtonPress={[Function]}
onChangeText={[Function]}
onSearchButtonPress={[Function]}
onSelectionChange={[Function]}
placeholder="Search"
placeholderTextColor="rgba(61,60,64,0.5)"
returnKeyType="search"
searchBarRightMargin={0}
searchIconSize={24}
showArrow={false}
showCancel={true}
testID="selector.search_bar"
tintColorDelete="rgba(61,60,64,0.5)"
tintColorSearch="rgba(61,60,64,0.5)"
titleCancelColor="#3d3c40"
value="name2"
/>
</View>
<CustomList
canRefresh={true}
data={Array []}
listType="flat"
loading={false}
loadingComponent={null}
noResults={null}
onLoadMore={[Function]}
onRowPress={[Function]}
renderItem={[Function]}
shouldRenderSeparator={true}
showNoResults={true}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</RNCSafeAreaView>
`;
exports[`SelectorScreen should match snapshot for users 1`] = `
<RNCSafeAreaView
style={
Object {
"flex": 1,
}
}
>
<Connect(StatusBar) />
<View
style={
Object {
"height": 38,
"marginVertical": 5,
"paddingLeft": 8,
}
}
testID="selector.screen"
>
<Search
autoCapitalize="none"
backArrowSize={24}
backgroundColor="transparent"
blurOnSubmit={false}
cancelTitle="Cancel"
containerHeight={40}
deleteIconSize={20}
editable={true}
inputHeight={33}
inputStyle={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"color": "#3d3c40",
"fontSize": 15,
}
}
keyboardAppearance="light"
keyboardShouldPersist={false}
keyboardType="default"
onBlur={[Function]}
onCancelButtonPress={[Function]}
onChangeText={[Function]}
onSearchButtonPress={[Function]}
onSelectionChange={[Function]}
placeholder="Search"
placeholderTextColor="rgba(61,60,64,0.5)"
returnKeyType="search"
searchBarRightMargin={0}
searchIconSize={24}
showArrow={false}
showCancel={true}
testID="selector.search_bar"
tintColorDelete="rgba(61,60,64,0.5)"
tintColorSearch="rgba(61,60,64,0.5)"
titleCancelColor="#3d3c40"
value=""
/>
</View>
<CustomList
canRefresh={true}
data={Array []}
listType="flat"
loading={false}
loadingComponent={null}
noResults={null}
onLoadMore={[Function]}
onRowPress={[Function]}
renderItem={[Function]}
shouldRenderSeparator={true}
showNoResults={true}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</RNCSafeAreaView>
`;
exports[`SelectorScreen should match snapshot for users 2`] = `
<RNCSafeAreaView
style={
Object {
"flex": 1,
}
}
>
<Connect(StatusBar) />
<View
style={
Object {
"height": 38,
"marginVertical": 5,
"paddingLeft": 8,
}
}
testID="selector.screen"
>
<Search
autoCapitalize="none"
backArrowSize={24}
backgroundColor="transparent"
blurOnSubmit={false}
cancelTitle="Cancel"
containerHeight={40}
deleteIconSize={20}
editable={true}
inputHeight={33}
inputStyle={
Object {
"backgroundColor": "rgba(61,60,64,0.2)",
"color": "#3d3c40",
"fontSize": 15,
}
}
keyboardAppearance="light"
keyboardShouldPersist={false}
keyboardType="default"
onBlur={[Function]}
onCancelButtonPress={[Function]}
onChangeText={[Function]}
onSearchButtonPress={[Function]}
onSelectionChange={[Function]}
placeholder="Search"
placeholderTextColor="rgba(61,60,64,0.5)"
returnKeyType="search"
searchBarRightMargin={0}
searchIconSize={24}
showArrow={false}
showCancel={true}
testID="selector.search_bar"
tintColorDelete="rgba(61,60,64,0.5)"
tintColorSearch="rgba(61,60,64,0.5)"
titleCancelColor="#3d3c40"
value=""
/>
</View>
<CustomList
canRefresh={true}
data={Array []}
listType="flat"
loading={false}
loadingComponent={null}
noResults={null}
onLoadMore={[Function]}
onRowPress={[Function]}
renderItem={[Function]}
shouldRenderSeparator={true}
showNoResults={true}
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</RNCSafeAreaView>
`;

View file

@ -0,0 +1,127 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import {IntlProvider} from 'react-intl';
import Preferences from '@mm-redux/constants/preferences';
import AppSelectorScreen from './app_selector_screen';
import {Channel} from '@mm-redux/types/channels';
import {UserProfile} from '@mm-redux/types/users';
const user1 = {id: 'id', username: 'username'} as UserProfile;
const user2 = {id: 'id2', username: 'username2'} as UserProfile;
const getProfiles = async () => {
return {
data: [user1, user2],
error: {},
};
};
const searchProfiles = async () => {
return {
data: [user2],
error: {},
};
};
const channel1 = {id: 'id', name: 'name', display_name: 'display_name'} as Channel;
const channel2 = {id: 'id2', name: 'name2', display_name: 'display_name2'} as Channel;
const getChannels = async () => {
return {
data: [channel1, channel2],
error: {},
};
};
const searchChannels = async () => {
return {
data: [channel2],
error: {},
};
};
const intlProvider = new IntlProvider({locale: 'en'}, {});
const {intl} = intlProvider.getChildContext();
describe('SelectorScreen', () => {
const actions = {
getProfiles,
getChannels,
searchProfiles,
searchChannels,
};
const baseProps = {
actions,
currentTeamId: 'someId',
onSelect: jest.fn(),
data: [{label: 'text', value: 'value'}],
theme: Preferences.THEMES.default,
};
beforeAll(() => {
jest.useFakeTimers();
});
test('should match snapshot for explicit options', async () => {
const wrapper = shallow(
<AppSelectorScreen {...baseProps}/>,
{context: {intl}},
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot for users', async () => {
const props = {
...baseProps,
dataSource: 'users',
data: [user1, user2],
};
const wrapper = shallow(
<AppSelectorScreen {...props}/>,
{context: {intl}},
);
expect(wrapper.getElement()).toMatchSnapshot();
wrapper.setState({isLoading: false});
wrapper.update();
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot for channels', async () => {
const props = {
...baseProps,
dataSource: 'channels',
data: [channel1, channel2],
};
const wrapper = shallow(
<AppSelectorScreen {...props}/>,
{context: {intl}},
);
expect(wrapper.getElement()).toMatchSnapshot();
wrapper.setState({isLoading: false});
wrapper.update();
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should match snapshot for searching', async () => {
const props = {
...baseProps,
dataSource: 'channels',
data: [channel1, channel2],
};
const wrapper = shallow(
<AppSelectorScreen {...props}/>,
{context: {intl}},
);
wrapper.setState({isLoading: false, searching: true, term: 'name2'});
wrapper.update();
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,465 @@
// 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 {
Platform,
View,
} from 'react-native';
import {SafeAreaView} from 'react-native-safe-area-context';
import {popTopScreen} from '@actions/navigation';
import CustomList, {FLATLIST} from '@components/custom_list';
import UserListRow from '@components/custom_list/user_list_row';
import ChannelListRow from '@components/custom_list/channel_list_row';
import OptionListRow from '@components/custom_list/option_list_row';
import FormattedText from '@components/formatted_text';
import SearchBar from '@components/search_bar';
import StatusBar from '@components/status_bar';
import {ViewTypes} from '@constants';
import {debounce} from '@mm-redux/actions/helpers';
import {General} from '@mm-redux/constants';
import {filterProfilesMatchingTerm} from '@mm-redux/utils/user_utils';
import {filterChannelsMatchingTerm} from '@mm-redux/utils/channel_utils';
import {memoizeResult} from '@mm-redux/utils/helpers';
import {t} from '@utils/i18n';
import {loadingText} from '@utils/member_list';
import {
changeOpacity,
makeStyleSheetFromTheme,
getKeyboardAppearanceFromTheme,
} from '@utils/theme';
import {AppSelectOption} from '@mm-redux/types/apps';
import {Theme} from '@mm-redux/types/preferences';
import {ActionResult} from '@mm-redux/types/actions';
import {Channel} from '@mm-redux/types/channels';
import {UserProfile} from '@mm-redux/types/users';
type Props = {
currentTeamId: string;
data: OptionsData;
dataSource?: string;
onSelect: (option: UserProfile | Channel | AppSelectOption) => void;
theme: Theme;
performLookupCall?: (userInput: string) => Promise<AppSelectOption[]>;
actions: {
getProfiles: (page?: number, perPage?: number, options?: any) => Promise<ActionResult>;
getChannels: (teamId: string, page?: number, perPage?: number) => Promise<ActionResult>;
searchProfiles: (term: string, options?: any) => Promise<ActionResult>;
searchChannels: (teamId: string, term: string, archived?: boolean) => Promise<ActionResult>;
}
}
type State = {
data: OptionsData;
loading: boolean;
searchResults: OptionsData;
term: string;
}
type OptionsData = UserProfile[] | Channel[] | AppSelectOption[];
type RowProps = {
id: string;
item: UserProfile | Channel | AppSelectOption;
selected: boolean;
selectable: boolean;
enabled: boolean;
onPress: (id: string, item: UserProfile | Channel | AppSelectOption) => void;
}
export default class AppSelectorScreen extends PureComponent<Props, State> {
private searchTimeoutId?: NodeJS.Timeout;
private page = -1;
private next: boolean;
static contextTypes = {
intl: intlShape.isRequired,
};
constructor(props: Props) {
super(props);
this.next = props.dataSource === ViewTypes.DATA_SOURCE_USERS ||
props.dataSource === ViewTypes.DATA_SOURCE_CHANNELS ||
props.dataSource === 'app';
let data: OptionsData = [];
if (!props.dataSource) {
data = props.data;
}
this.state = {
data,
loading: false,
searchResults: [],
term: '',
};
}
componentDidMount() {
const {dataSource} = this.props;
if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
this.getProfiles();
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
this.getChannels();
} else if (dataSource === 'app') {
this.getAppOptions();
}
}
clearSearch = () => {
this.setState({term: '', searchResults: []});
};
close = () => {
popTopScreen();
};
handleSelectItem = (id: string, item: UserProfile | Channel | AppSelectOption) => {
this.props.onSelect(item);
this.close();
};
getChannels = debounce(() => {
const {actions, currentTeamId} = this.props;
const {loading, term} = this.state;
if (this.next && !loading && !term) {
this.setState({loading: true}, () => {
actions.getChannels(
currentTeamId,
this.page += 1,
General.CHANNELS_CHUNK_SIZE,
).then(this.loadedChannels);
});
}
}, 100);
getAppOptions = debounce(() => {
const {performLookupCall} = this.props;
const {loading, term} = this.state;
if (this.next && !loading && !term) {
this.setState({loading: true}, () => {
performLookupCall?.(term).then(this.loadedAppOptions);
});
}
}, 100)
getDataResults = () => {
const {dataSource} = this.props;
const {data, searchResults, term} = this.state;
const result = {
data,
listType: FLATLIST};
if (term) {
result.data = filterSearchData(dataSource, searchResults, term);
}
// TODO re-add this to add sections by first username letter if desired
// } else if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
// result.data = createProfilesSections(data);
// result.listType = SECTIONLIST;
// }
return result;
};
getProfiles = debounce(() => {
const {loading, term} = this.state;
if (this.next && !loading && !term) {
this.setState({loading: true}, () => {
const {actions} = this.props;
actions.getProfiles(
this.page + 1,
General.PROFILE_CHUNK_SIZE,
).then(this.loadedProfiles);
});
}
}, 100);
loadedChannels = ({data: channels}: {data: Channel[]}) => {
const data = this.state.data as Channel[];
if (channels && !channels.length) {
this.next = false;
}
this.page += 1;
this.setState({loading: false, data: [...channels, ...data]});
};
loadedProfiles = ({data: profiles}: {data: UserProfile[]}) => {
const data = this.state.data as UserProfile[];
if (profiles && !profiles.length) {
this.next = false;
}
this.page += 1;
this.setState({loading: false, data: [...profiles, ...data]});
};
loadedAppOptions = (options: AppSelectOption[]) => {
const data = this.state.data as AppSelectOption[];
if (options && !options.length) {
this.next = false;
}
this.page += 1;
this.setState({loading: false, data: [...options, ...data]});
}
loadMore = () => {
const {dataSource} = this.props;
if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
this.getProfiles();
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
this.getChannels();
}
};
onSearch = (text: string) => {
if (text) {
const {dataSource, data} = this.props;
this.setState({term: text});
if (this.searchTimeoutId) {
clearTimeout(this.searchTimeoutId);
}
this.searchTimeoutId = setTimeout(() => {
if (!dataSource) {
this.setState({searchResults: filterSearchData(null, data, text)});
return;
}
if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
this.searchProfiles(text);
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
this.searchChannels(text);
}
}, General.SEARCH_TIMEOUT_MILLISECONDS);
} else {
this.clearSearch();
}
};
searchChannels = (term: string) => {
const {actions, currentTeamId} = this.props;
actions.searchChannels(currentTeamId, term.toLowerCase()).then(({data}) => {
this.setState({searchResults: data, loading: false});
});
};
searchProfiles = (term: string) => {
const {actions} = this.props;
this.setState({loading: true});
actions.searchProfiles(term.toLowerCase()).then((results) => {
let data = [];
if (results.data) {
data = results.data;
}
this.setState({searchResults: data, loading: false});
});
};
renderLoading = () => {
const {dataSource, theme} = this.props;
const {loading} = this.state;
const style = getStyleFromTheme(theme);
if (!loading) {
return null;
}
let text;
switch (dataSource) {
case ViewTypes.DATA_SOURCE_USERS:
text = loadingText;
break;
case ViewTypes.DATA_SOURCE_CHANNELS:
text = {
id: t('mobile.loading_channels'),
defaultMessage: 'Loading Channels...',
};
break;
default:
text = {
id: t('mobile.loading_options'),
defaultMessage: 'Loading Options...',
};
break;
}
return (
<View style={style.loadingContainer}>
<FormattedText
{...text}
style={style.loadingText}
/>
</View>
);
};
renderNoResults = () => {
const {loading} = this.state;
const {theme} = this.props;
const style = getStyleFromTheme(theme);
if (loading || this.page === -1) {
return null;
}
return (
<View style={style.noResultContainer}>
<FormattedText
id='mobile.custom_list.no_results'
defaultMessage='No Results'
style={style.noResultText}
/>
</View>
);
};
renderChannelItem = (props: RowProps) => {
return <ChannelListRow {...props}/>;
};
renderOptionItem = (props: RowProps) => {
const item = props.item as AppSelectOption;
const newProps = {
...props,
item: {text: item.label, value: item.value},
onPress: (id: string, option: {text: string, value: string}) => {
props.onPress(id, {label: option.text, value: option.value});
},
};
return <OptionListRow {...newProps}/>;
};
renderUserItem = (props: RowProps) => {
return <UserListRow {...props}/>;
};
render() {
const {formatMessage} = this.context.intl;
const {theme, dataSource} = this.props;
const {loading, term} = this.state;
const style = getStyleFromTheme(theme);
const searchBarInput = {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.2),
color: theme.centerChannelColor,
fontSize: 15,
};
let rowComponent;
if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
rowComponent = this.renderUserItem;
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
rowComponent = this.renderChannelItem;
} else {
rowComponent = this.renderOptionItem;
}
const {data, listType} = this.getDataResults();
return (
<SafeAreaView style={style.container}>
<StatusBar/>
<View
testID='selector.screen'
style={style.searchBar}
>
<SearchBar
testID='selector.search_bar'
placeholder={formatMessage({id: 'search_bar.search', defaultMessage: 'Search'})}
cancelTitle={formatMessage({id: 'mobile.post.cancel', defaultMessage: 'Cancel'})}
backgroundColor='transparent'
inputHeight={33}
inputStyle={searchBarInput}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
tintColorSearch={changeOpacity(theme.centerChannelColor, 0.5)}
tintColorDelete={changeOpacity(theme.centerChannelColor, 0.5)}
titleCancelColor={theme.centerChannelColor}
onChangeText={this.onSearch}
onSearchButtonPress={this.onSearch}
onCancelButtonPress={this.clearSearch}
autoCapitalize='none'
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
value={term}
/>
</View>
<CustomList
data={data}
key='custom_list'
listType={listType}
loading={loading}
loadingComponent={this.renderLoading()}
noResults={this.renderNoResults()}
onLoadMore={this.loadMore}
onRowPress={this.handleSelectItem}
renderItem={rowComponent}
theme={theme}
/>
</SafeAreaView>
);
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flex: 1,
},
searchBar: {
marginVertical: 5,
height: 38,
...Platform.select({
ios: {
paddingLeft: 8,
},
}),
},
loadingContainer: {
alignItems: 'center',
backgroundColor: theme.centerChannelBg,
height: 70,
justifyContent: 'center',
},
loadingText: {
color: changeOpacity(theme.centerChannelColor, 0.6),
},
noResultContainer: {
flexGrow: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
noResultText: {
fontSize: 26,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
};
});
const filterSearchData = memoizeResult((dataSource: string, data: OptionsData, term: string): OptionsData => {
if (!data) {
return [];
}
const lowerCasedTerm = term.toLowerCase();
if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
const users = data as UserProfile[];
return filterProfilesMatchingTerm(users, lowerCasedTerm);
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
const channels = data as Channel[];
return filterChannelsMatchingTerm(channels, lowerCasedTerm);
}
const options = data as AppSelectOption[];
return options.filter((option) => option.label && option.label.toLowerCase().startsWith(lowerCasedTerm));
});

View file

@ -0,0 +1,40 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {connect} from 'react-redux';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getProfiles, searchProfiles} from '@mm-redux/actions/users';
import {getChannels, searchChannels} from '@mm-redux/actions/channels';
import AppSelectorScreen from './app_selector_screen';
import {ActionFunc, ActionResult, GenericAction} from '@mm-redux/types/actions';
import {GlobalState} from '@mm-redux/types/store';
function mapStateToProps(state: GlobalState) {
return {
currentTeamId: getCurrentTeamId(state),
theme: getTheme(state),
};
}
type Actions = {
getProfiles: (page?: number, perPage?: number, options?: any) => Promise<ActionResult>;
getChannels: (teamId: string, page?: number, perPage?: number) => Promise<ActionResult>;
searchProfiles: (term: string, options?: any) => Promise<ActionResult>;
searchChannels: (teamId: string, term: string, archived?: boolean) => Promise<ActionResult>;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
getProfiles,
getChannels,
searchProfiles,
searchChannels,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(AppSelectorScreen);

View file

@ -0,0 +1,126 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DialogIntroductionText should not render the component with an empty value 1`] = `null`;
exports[`DialogIntroductionText should render the introduction text correctly 1`] = `
<View
style={
Object {
"marginHorizontal": 15,
}
}
>
<Connect(Markdown)
baseTextStyle={
Object {
"color": "#3d3c40",
}
}
blockStyles={
Object {
"adjacentParagraph": Object {
"marginTop": 6,
},
"horizontalRule": Object {
"backgroundColor": "#3d3c40",
"height": 0.5,
"marginVertical": 10,
},
"quoteBlockIcon": Object {
"color": "rgba(61,60,64,0.5)",
},
}
}
disableAtMentions={true}
disableChannelLink={true}
disableGallery={true}
disableHashtags={true}
textStyles={
Object {
"code": Object {
"alignSelf": "center",
"backgroundColor": "rgba(61,60,64,0.07)",
"fontFamily": "Menlo",
},
"codeBlock": Object {
"fontFamily": "Menlo",
},
"del": Object {
"textDecorationLine": "line-through",
},
"emph": Object {
"fontStyle": "italic",
},
"error": Object {
"color": "#fd5960",
},
"heading1": Object {
"fontSize": 17,
"fontWeight": "700",
"lineHeight": 25,
},
"heading1Text": Object {
"paddingBottom": 8,
},
"heading2": Object {
"fontSize": 17,
"fontWeight": "700",
"lineHeight": 25,
},
"heading2Text": Object {
"paddingBottom": 8,
},
"heading3": Object {
"fontSize": 17,
"fontWeight": "700",
"lineHeight": 25,
},
"heading3Text": Object {
"paddingBottom": 8,
},
"heading4": Object {
"fontSize": 17,
"fontWeight": "700",
"lineHeight": 25,
},
"heading4Text": Object {
"paddingBottom": 8,
},
"heading5": Object {
"fontSize": 17,
"fontWeight": "700",
"lineHeight": 25,
},
"heading5Text": Object {
"paddingBottom": 8,
},
"heading6": Object {
"fontSize": 17,
"fontWeight": "700",
"lineHeight": 25,
},
"heading6Text": Object {
"paddingBottom": 8,
},
"link": Object {
"color": "#2389d7",
},
"mention": Object {
"color": "#2389d7",
},
"mention_highlight": Object {
"backgroundColor": "#ffe577",
"color": "#166de0",
},
"strong": Object {
"fontWeight": "bold",
},
"table_header_row": Object {
"fontWeight": "700",
},
}
}
value="**bold** *italic* [link](https://mattermost.com/) <br/> [link target blank](!https://mattermost.com/)"
/>
</View>
`;

View file

@ -0,0 +1,280 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent, ReactNode} from 'react';
import {Text, View} from 'react-native';
import {intlShape} from 'react-intl';
import {displayUsername} from '@mm-redux/utils/user_utils';
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import {ViewTypes} from '@constants';
import {goToScreen} from '@actions/navigation';
import {AppSelectOption} from '@mm-redux/types/apps';
import {Theme} from '@mm-redux/types/preferences';
import {UserProfile} from '@mm-redux/types/users';
import {Channel} from '@mm-redux/types/channels';
type Props = {
label?: string;
placeholder?: string;
dataSource?: string;
options?: AppSelectOption[];
selected?: AppSelectOption;
optional?: boolean;
showRequiredAsterisk?: boolean;
teammateNameDisplay?: string;
theme: Theme;
onSelected?: (option: AppSelectOption) => void;
helpText?: string;
errorText?: ReactNode;
roundedBorders?: boolean;
disabled?: boolean;
performLookupCall?: (term: string) => Promise<AppSelectOption[]>;
}
export default class AppFormSelector extends PureComponent<Props> {
static contextTypes = {
intl: intlShape,
};
static defaultProps = {
optional: false,
showRequiredAsterisk: false,
roundedBorders: true,
};
handleSelect = (selected: UserProfile | Channel | AppSelectOption) => {
if (!selected) {
return;
}
const {
dataSource,
teammateNameDisplay,
} = this.props;
let selectedLabel;
let selectedValue;
if (dataSource === ViewTypes.DATA_SOURCE_USERS) {
const user = selected as UserProfile;
selectedLabel = user.username;
if (teammateNameDisplay) {
selectedLabel = displayUsername(user, teammateNameDisplay);
}
selectedValue = user.id;
} else if (dataSource === ViewTypes.DATA_SOURCE_CHANNELS) {
const channel = selected as Channel;
selectedLabel = channel.display_name;
selectedValue = channel.id;
} else {
const option = selected as AppSelectOption;
selectedLabel = option.label;
selectedValue = option.value;
}
const selectedOption = {label: selectedLabel, value: selectedValue};
if (this.props.onSelected) {
this.props.onSelected(selectedOption);
}
};
goToSelectorScreen = preventDoubleTap(() => {
const {formatMessage} = this.context.intl;
const {dataSource, options, placeholder, performLookupCall} = this.props;
const screen = 'AppSelectorScreen';
const title = placeholder || formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'});
const selectorProps = {
data: options,
dataSource,
onSelect: this.handleSelect,
performLookupCall,
};
goToScreen(screen, title, selectorProps);
});
render() {
const {intl} = this.context;
const {
placeholder,
theme,
label,
helpText,
errorText,
optional,
showRequiredAsterisk,
roundedBorders,
disabled,
selected,
} = this.props;
const style = getStyleSheet(theme);
let text = placeholder || intl.formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'});
let selectedStyle = style.dropdownPlaceholder;
if (selected) {
text = selected.label;
selectedStyle = style.dropdownSelected;
}
let inputStyle = style.input;
if (roundedBorders) {
inputStyle = style.roundedInput;
}
let optionalContent;
let asterisk;
if (optional) {
optionalContent = (
<FormattedText
style={style.optional}
id='channel_modal.optional'
defaultMessage='(optional)'
/>
);
} else if (showRequiredAsterisk) {
asterisk = <Text style={style.asterisk}>{' *'}</Text>;
}
let labelContent;
if (label) {
labelContent = (
<View style={style.labelContainer}>
<Text style={style.label}>
{label}
</Text>
{asterisk}
{optionalContent}
</View>
);
}
let helpTextContent;
if (helpText) {
helpTextContent = (
<Text style={style.helpText}>
{helpText}
</Text>
);
}
let errorTextContent;
if (errorText) {
errorTextContent = (
<Text style={style.errorText}>
{errorText}
</Text>
);
}
return (
<View style={style.container}>
{labelContent}
<TouchableWithFeedback
style={disabled ? style.disabled : null}
onPress={this.goToSelectorScreen}
type={'opacity'}
disabled={disabled}
>
<View style={inputStyle}>
<Text
style={selectedStyle}
numberOfLines={1}
>
{text}
</Text>
<CompassIcon
name='chevron-down'
color={changeOpacity(theme.centerChannelColor, 0.5)}
style={style.icon}
/>
</View>
</TouchableWithFeedback>
{helpTextContent}
{errorTextContent}
</View>
);
}
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const input = {
borderWidth: 1,
borderColor: changeOpacity(theme.centerChannelColor, 0.1),
backgroundColor: changeOpacity(theme.centerChannelBg, 0.9),
paddingLeft: 10,
paddingRight: 30,
paddingVertical: 7,
height: 40,
};
return {
container: {
width: '100%',
marginBottom: 2,
marginRight: 8,
marginTop: 10,
},
roundedInput: {
...input,
borderRadius: 5,
},
input,
dropdownPlaceholder: {
top: 3,
marginLeft: 5,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
dropdownSelected: {
top: 3,
marginLeft: 5,
color: theme.centerChannelColor,
},
icon: {
position: 'absolute',
top: 13,
right: 12,
},
labelContainer: {
flexDirection: 'row',
marginTop: 15,
marginBottom: 10,
},
label: {
fontSize: 14,
color: theme.centerChannelColor,
marginLeft: 15,
},
optional: {
color: changeOpacity(theme.centerChannelColor, 0.5),
fontSize: 14,
marginLeft: 5,
},
helpText: {
fontSize: 12,
color: changeOpacity(theme.centerChannelColor, 0.5),
marginHorizontal: 15,
marginVertical: 10,
},
errorText: {
fontSize: 12,
color: theme.errorTextColor,
marginHorizontal: 15,
marginVertical: 10,
},
asterisk: {
color: theme.errorTextColor,
fontSize: 14,
},
disabled: {
opacity: 0.5,
},
};
});

View file

@ -0,0 +1,18 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {getTeammateNameDisplaySetting, getTheme} from '@mm-redux/selectors/entities/preferences';
import AppFormSelector from './app_form_selector';
import {GlobalState} from '@mm-redux/types/store';
function mapStateToProps(state: GlobalState) {
return {
teammateNameDisplay: getTeammateNameDisplaySetting(state),
theme: getTheme(state),
};
}
export default connect(mapStateToProps)(AppFormSelector);

View file

@ -0,0 +1,390 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {intlShape} from 'react-intl';
import React, {PureComponent} from 'react';
import {ScrollView} from 'react-native';
import {EventSubscription, Navigation} from 'react-native-navigation';
import {SafeAreaView} from 'react-native-safe-area-context';
import {checkDialogElementForError, checkIfErrorsMatchElements} from '@mm-redux/utils/integration_utils';
import ErrorText from 'app/components/error_text';
import StatusBar from 'app/components/status_bar';
import FormattedText from 'app/components/formatted_text';
import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme';
import {dismissModal} from 'app/actions/navigation';
import DialogIntroductionText from './dialog_introduction_text';
import {Theme} from '@mm-redux/types/preferences';
import {AppCallRequest, AppCallResponse, AppField, AppForm, AppFormValue, AppFormValues, AppLookupResponse, AppSelectOption, FormResponseData} from '@mm-redux/types/apps';
import {DialogElement} from '@mm-redux/types/integrations';
import {AppCallResponseTypes} from '@mm-redux/constants/apps';
import AppsFormField from './apps_form_field';
export type Props = {
call: AppCallRequest;
form: AppForm;
actions: {
submit: (submission: {
values: {
[name: string]: string;
};
}) => Promise<{data?: AppCallResponse<FormResponseData>, error?: AppCallResponse<FormResponseData>}>;
performLookupCall: (field: AppField, values: AppFormValues, userInput: string) => Promise<{data?: AppCallResponse<AppLookupResponse>, error?: AppCallResponse<AppLookupResponse>}>;
refreshOnSelect: (field: AppField, values: AppFormValues, value: AppFormValue) => Promise<{data?: AppCallResponse<FormResponseData>, error?: AppCallResponse<FormResponseData>}>;
};
theme: Theme;
componentId: string;
}
type State = {
values: {[name: string]: string};
formError: string | null;
fieldErrors: {[name: string]: React.ReactNode};
submitting: boolean;
form: AppForm;
}
const initFormValues = (form: AppForm): {[name: string]: string} => {
const values: {[name: string]: any} = {};
if (form && form.fields) {
form.fields.forEach((f) => {
values[f.name] = f.value || null;
});
}
return values;
};
export default class AppsFormComponent extends PureComponent<Props, State> {
private scrollView: React.RefObject<ScrollView>;
navigationEventListener?: EventSubscription;
static contextTypes = {
intl: intlShape.isRequired,
};
constructor(props: Props) {
super(props);
const {form} = props;
const values = initFormValues(form);
this.state = {
values,
formError: null,
fieldErrors: {},
submitting: false,
form,
};
this.scrollView = React.createRef();
}
static getDerivedStateFromProps(nextProps: Props, prevState: State) {
if (nextProps.form !== prevState.form) {
return {
values: initFormValues(nextProps.form),
form: nextProps.form,
};
}
return null;
}
componentDidMount() {
this.navigationEventListener = Navigation.events().bindComponent(this);
}
navigationButtonPressed({buttonId}: {buttonId: string}) {
switch (buttonId) {
case 'submit-form':
this.handleSubmit();
return;
case 'close-dialog':
this.handleHide();
return;
}
if (buttonId.startsWith('submit-form_')) {
this.handleSubmit(buttonId.substr('submit-form_'.length));
}
}
handleSubmit = async (button?: string) => {
const {fields} = this.props.form;
const values = this.state.values;
const fieldErrors: {[name: string]: React.ReactNode} = {};
const elements = fieldsAsElements(fields);
elements?.forEach((element) => {
const error = checkDialogElementForError( // TODO: make sure all required values are present in `element`
element,
values[element.name],
);
if (error) {
fieldErrors[element.name] = (
<FormattedText
id={error.id}
defaultMessage={error.defaultMessage}
values={error.values}
/>
);
}
});
this.setState({fieldErrors});
if (Object.keys(fieldErrors).length !== 0) {
return;
}
const submission = {
values,
};
if (button && this.props.form.submit_buttons) {
submission.values[this.props.form.submit_buttons] = button;
}
const res = await this.props.actions.submit(submission);
if (res.error) {
const errorResponse = res.error;
const errorMessage = errorResponse.error;
const hasErrors = this.updateErrors(elements, errorResponse.data?.errors, errorMessage);
if (!hasErrors) {
this.handleHide();
}
return;
}
const callResponse = res.data!;
switch (callResponse.type) {
case AppCallResponseTypes.OK:
case AppCallResponseTypes.NAVIGATE:
this.handleHide();
return;
case AppCallResponseTypes.FORM:
return;
default:
this.updateErrors([], undefined, this.context.intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResponse.type,
}));
}
}
updateErrors = (elements: DialogElement[], fieldErrors?: {[x: string]: string}, formError?: string): boolean => {
let hasErrors = false;
if (fieldErrors &&
Object.keys(fieldErrors).length >= 0 &&
checkIfErrorsMatchElements(fieldErrors as any, elements)
) {
hasErrors = true;
this.setState({fieldErrors});
}
if (formError) {
hasErrors = true;
this.setState({formError});
if (this.scrollView?.current) {
this.scrollView.current.scrollTo({x: 0, y: 0});
}
}
return hasErrors;
}
performLookup = async (name: string, userInput: string): Promise<AppSelectOption[]> => {
const intl = this.context.intl;
const field = this.props.form.fields.find((f) => f.name === name);
if (!field) {
return [];
}
const res = await this.props.actions.performLookupCall(field, this.state.values, userInput);
if (res.error) {
const errorResponse = res.error;
const errMsg = errorResponse.error || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error.',
});
this.setState({
fieldErrors: {
...this.state.fieldErrors,
[field.name]: errMsg,
},
});
return [];
}
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.OK:
return callResp.data?.items || [];
case AppCallResponseTypes.FORM:
case AppCallResponseTypes.NAVIGATE: {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.unexpected_type',
defaultMessage: 'App response type was not expected. Response type: {type}.',
}, {
type: callResp.type,
},
);
this.setState({
fieldErrors: {
...this.state.fieldErrors,
[field.name]: errMsg,
},
});
return [];
}
default: {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
},
);
this.setState({
fieldErrors: {
...this.state.fieldErrors,
[field.name]: errMsg,
},
});
return [];
}
}
}
handleHide = () => {
dismissModal();
}
onChange = (name: string, value: any) => {
const field = this.props.form.fields.find((f) => f.name === name);
if (!field) {
return;
}
const values = {...this.state.values, [name]: value};
if (field.refresh) {
this.props.actions.refreshOnSelect(field, values, value).then((res) => {
if (res.error) {
const errorResponse = res.error;
const errorMsg = errorResponse.error;
const errors = errorResponse.data?.errors;
const elements = fieldsAsElements(this.props.form.fields);
this.updateErrors(elements, errors, errorMsg);
return;
}
const callResponse = res.data!;
switch (callResponse.type) {
case AppCallResponseTypes.FORM:
return;
case AppCallResponseTypes.OK:
case AppCallResponseTypes.NAVIGATE:
this.updateErrors([], undefined, this.context.intl.formatMessage({
id: 'apps.error.responses.unexpected_type',
defaultMessage: 'App response type was not expected. Response type: {type}.',
}, {
type: callResponse.type,
}));
return;
default:
this.updateErrors([], undefined, this.context.intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResponse.type,
}));
}
});
}
this.setState({values});
};
render() {
const {theme, form} = this.props;
const {fields, header} = form;
const {formError, fieldErrors, values} = this.state;
const style = getStyleFromTheme(theme);
return (
<SafeAreaView
testID='interactive_dialog.screen'
style={style.container}
>
<ScrollView
ref={this.scrollView}
style={style.scrollView}
>
<StatusBar/>
{formError && (
<ErrorText
testID='interactive_dialog.error.text'
textStyle={style.errorContainer}
error={formError}
/>
)}
{header &&
<DialogIntroductionText
value={header}
theme={theme}
/>
}
{fields && fields.filter((f) => f.name !== form.submit_buttons).map((field) => {
return (
<AppsFormField
field={field}
key={field.name}
name={field.name}
errorText={fieldErrors[field.name]}
value={values[field.name]}
performLookup={this.performLookup}
onChange={this.onChange}
theme={theme}
/>
);
})}
</ScrollView>
</SafeAreaView>
);
}
}
function fieldsAsElements(fields?: AppField[]): DialogElement[] {
return fields?.map((f) => ({
name: f.name,
type: f.type,
subtype: f.subtype,
optional: !f.is_required,
})) as DialogElement[];
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.03),
},
errorContainer: {
marginTop: 15,
marginLeft: 15,
fontSize: 14,
fontWeight: 'bold',
},
scrollView: {
marginBottom: 20,
marginTop: 10,
},
};
});

View file

@ -0,0 +1,241 @@
// 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 {Theme} from '@mm-redux/types/preferences';
import {AppCallResponse, AppCallRequest, AppField, AppForm, AppFormValues, FormResponseData, AppCallType, AppLookupResponse} from '@mm-redux/types/apps';
import {AppCallResponseTypes, AppCallTypes} from '@mm-redux/constants/apps';
import AppsFormComponent from './apps_form_component';
import {makeCallErrorResponse} from '@utils/apps';
import {ActionResult} from '@mm-redux/types/actions';
export type Props = {
form?: AppForm;
call?: AppCallRequest;
actions: {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse<any>, error?: AppCallResponse<any>}>;
sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise<ActionResult>;
};
theme: Theme;
componentId: string;
};
export type State = {
form?: AppForm;
}
export default class AppsFormContainer extends PureComponent<Props, State> {
static contextTypes = {
intl: intlShape.isRequired,
};
constructor(props: Props) {
super(props);
this.state = {form: props.form};
}
handleSubmit = async (submission: {values: AppFormValues}): Promise<{data?: AppCallResponse<FormResponseData>, error?: AppCallResponse<FormResponseData>}> => {
const intl = this.context.intl;
const makeErrorMsg = (msg: string) => {
return intl.formatMessage(
{
id: 'apps.error.form.submit.pretext',
defaultMessage: 'There has been an error submitting the modal. Contact the app developer. Details: {details}',
},
{details: msg},
);
};
const {form} = this.state;
if (!form) {
return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage(
{
id: 'apps.error.form.no_form',
defaultMessage: '`form` is not defined',
},
)))};
}
const call = this.getCall();
if (!call) {
return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage(
{
id: 'apps.error.form.no_call',
defaultMessage: '`call` is not defined',
},
)))};
}
const res = await this.props.actions.doAppCall({
...call,
values: submission.values,
}, AppCallTypes.SUBMIT, intl);
if (res.error) {
return res;
}
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
this.props.actions.sendEphemeralPost(callResp.markdown);
}
break;
case AppCallResponseTypes.FORM:
this.setState({form: callResp.form});
break;
case AppCallResponseTypes.NAVIGATE:
break;
default:
return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage(
{
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
},
)))};
}
return res;
}
refreshOnSelect = async (field: AppField, values: AppFormValues): Promise<{data?: AppCallResponse<FormResponseData>, error?: AppCallResponse<FormResponseData>}> => {
const intl = this.context.intl;
const makeErrorMsg = (message: string) => intl.formatMessage(
{
id: 'apps.error.form.refresh',
defaultMessage: 'There has been an error updating the modal. Contact the app developer. Details: {details}',
},
{details: message},
);
const {form} = this.state;
if (!form) {
return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage({
id: 'apps.error.form.no_form',
defaultMessage: '`form` is not defined.',
})))};
}
const call = this.getCall();
if (!call) {
return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage({
id: 'apps.error.form.no_call',
defaultMessage: '`call` is not defined.',
})))};
}
if (!field.refresh) {
// Should never happen
return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage({
id: 'apps.error.form.refresh_no_refresh',
defaultMessage: 'Called refresh on no refresh field.',
})))};
}
const res = await this.props.actions.doAppCall({
...call,
selected_field: field.name,
values,
}, AppCallTypes.FORM, intl);
if (res.error) {
return res;
}
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.FORM:
this.setState({form: callResp.form});
break;
case AppCallResponseTypes.OK:
case AppCallResponseTypes.NAVIGATE:
return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage({
id: 'apps.error.responses.unexpected_type',
defaultMessage: 'App response type was not expected. Response type: {type}.',
}, {
type: callResp.type,
},
)))};
default:
return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
},
)))};
}
return res;
};
performLookupCall = async (field: AppField, values: AppFormValues, userInput: string): Promise<{data?: AppCallResponse<AppLookupResponse>, error?: AppCallResponse<AppLookupResponse>}> => {
const intl = this.context.intl;
const makeErrorMsg = (message: string) => intl.formatMessage(
{
id: 'apps.error.form.refresh',
defaultMessage: 'There has been an error fetching the select fields. Contact the app developer. Details: {details}',
},
{details: message},
);
const call = this.getCall();
if (!call) {
return makeErrorMsg(intl.formatMessage({id: 'apps.error.form.no_lookup_call', defaultMessage: 'performLookupCall props.call is not defined'}));
}
return this.props.actions.doAppCall({
...call,
values,
selected_field: field.name,
query: userInput,
}, AppCallTypes.LOOKUP, intl);
}
getCall = (): AppCallRequest | null => {
const {form} = this.state;
const {call} = this.props;
if (!call) {
return null;
}
return {
...call,
...form?.call,
context: {
...call.context,
},
values: {
...call.values,
},
};
}
render() {
const {form} = this.state;
if (!form) {
return null;
}
const call = this.getCall();
if (!call) {
return null;
}
return (
<AppsFormComponent
form={form}
call={call}
actions={{
submit: this.handleSubmit,
performLookupCall: this.performLookupCall,
refreshOnSelect: this.refreshOnSelect,
}}
theme={this.props.theme}
componentId={this.props.componentId}
/>
);
}
}

View file

@ -0,0 +1,150 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import BoolSetting from '@components/widgets/settings/bool_setting';
import TextSetting from '@components/widgets/settings/text_setting';
import {ViewTypes} from '@constants/index';
import {AppField, AppFormValue, AppSelectOption} from '@mm-redux/types/apps';
import {Theme} from '@mm-redux/types/preferences';
import React from 'react';
import AppFormSelector from './app_form_selector';
const TEXT_DEFAULT_MAX_LENGTH = 150;
const TEXTAREA_DEFAULT_MAX_LENGTH = 3000;
export type Props = {
field: AppField;
name: string;
errorText?: React.ReactNode;
theme: Theme;
value: AppFormValue;
onChange: (name: string, value: string | AppSelectOption) => void;
performLookup: (name: string, userInput: string) => Promise<AppSelectOption[]>;
}
export default class AppsFormField extends React.PureComponent<Props> {
render() {
const {
field,
name,
value,
onChange,
errorText,
theme,
} = this.props;
const placeholder = field.hint || '';
const displayName = (field.modal_label || field.label) as string;
if (field.type === 'text') {
let keyboardType = 'default';
let multiline = false;
let secureTextEntry = false;
const subtype = field.subtype || 'text';
let maxLength = field.max_length;
if (!maxLength) {
if (subtype === 'textarea') {
maxLength = TEXTAREA_DEFAULT_MAX_LENGTH;
} else {
maxLength = TEXT_DEFAULT_MAX_LENGTH;
}
}
let textType = 'input';
if (subtype && TextSetting.validTypes.includes(subtype)) {
textType = subtype;
}
switch (textType) {
case 'email':
keyboardType = 'email-address';
break;
case 'number':
keyboardType = 'numeric';
break;
case 'tel':
keyboardType = 'phone-pad';
break;
case 'url':
keyboardType = 'url';
break;
case 'password':
secureTextEntry = true;
break;
case 'textarea':
multiline = true;
break;
}
const textValue = value as string;
return (
<TextSetting
id={name}
label={displayName}
maxLength={maxLength}
value={textValue || ''}
placeholder={placeholder}
helpText={field.description}
errorText={errorText}
onChange={onChange}
optional={!field.is_required}
showRequiredAsterisk={true}
resizable={false}
theme={theme}
multiline={multiline}
keyboardType={keyboardType}
secureTextEntry={secureTextEntry}
disabled={field.readonly}
/>
);
} else if (field.type === 'channel' || field.type === 'user' || field.type === 'dynamic_select' || field.type === 'static_select') {
let dataSource = ViewTypes.DATA_SOURCE_CHANNELS;
if (field.type === 'user') {
dataSource = ViewTypes.DATA_SOURCE_USERS;
}
if (field.type === 'dynamic_select') {
dataSource = 'app';
}
const option = value as AppSelectOption;
return (
<AppFormSelector
label={displayName}
options={field.options}
dataSource={dataSource}
optional={!field.is_required}
onSelected={(selected: AppSelectOption) => this.props.onChange(field.name, selected)}
helpText={field.description}
errorText={errorText}
placeholder={placeholder}
showRequiredAsterisk={true}
selected={option}
roundedBorders={false}
disabled={field.readonly}
performLookupCall={(term: string) => this.props.performLookup(field.name, term)}
/>
);
} else if (field.type === 'bool') {
const boolValue = value as boolean;
return (
<BoolSetting
id={name}
label={displayName}
value={boolValue || false}
placeholder={placeholder}
helpText={field.description}
errorText={errorText}
optional={!field.is_required}
theme={theme}
onChange={onChange}
disabled={field.readonly}
/>
);
}
return null;
}
}

View file

@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {shallow} from 'enzyme';
import Preferences from '@mm-redux/constants/preferences';
import DialogIntroductionText from './dialog_introduction_text';
describe('DialogIntroductionText', () => {
const baseProps = {
theme: Preferences.THEMES.default,
value: '**bold** *italic* [link](https://mattermost.com/) <br/> [link target blank](!https://mattermost.com/)',
};
test('should render the introduction text correctly', () => {
const wrapper = shallow(
<DialogIntroductionText
{...baseProps}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
test('should not render the component with an empty value', () => {
baseProps.value = '';
const wrapper = shallow(
<DialogIntroductionText
{...baseProps}
/>,
);
expect(wrapper.getElement()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {PureComponent} from 'react';
import {View} from 'react-native';
import {Theme} from '@mm-redux/types/preferences';
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
import Markdown from '@components/markdown';
import {makeStyleSheetFromTheme} from '@utils/theme';
type Props = {
value: string;
theme: Theme;
}
export default class DialogIntroductionText extends PureComponent<Props> {
render() {
const {
value,
theme,
} = this.props;
if (value) {
const style = getStyleFromTheme(theme);
const blockStyles = getMarkdownBlockStyles(theme);
const textStyles = getMarkdownTextStyles(theme);
return (
<View style={style.introductionTextView}>
<Markdown
baseTextStyle={style.introductionText}
disableGallery={true}
textStyles={textStyles}
blockStyles={blockStyles}
value={value}
disableHashtags={true}
disableAtMentions={true}
disableChannelLink={true}
/>
</View>
);
}
return null;
}
}
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
introductionTextView: {
marginHorizontal: 15,
},
introductionText: {
color: theme.centerChannelColor,
},
};
});

View file

@ -0,0 +1,37 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {connect} from 'react-redux';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {doAppCall} from '@actions/apps';
import {AppCallResponse, AppCallRequest, AppCallType} from '@mm-redux/types/apps';
import {GlobalState} from '@mm-redux/types/store';
import {ActionFunc, ActionResult, GenericAction} from '@mm-redux/types/actions';
import AppsFormContainer from './apps_form_container';
import {sendEphemeralPost} from '@actions/views/post';
type Actions = {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise<ActionResult>;
};
function mapStateToProps(state: GlobalState) {
return {
theme: getTheme(state),
};
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
doAppCall,
sendEphemeralPost,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(AppsFormContainer);

View file

@ -27,6 +27,7 @@ exports[`ChannelPostList should match snapshot 1`] = `
indicateNewMessages={true}
lastPostIndex={-1}
loadMorePostsVisible={false}
location="channel"
onLoadMoreUp={[Function]}
onPostPress={[Function]}
postIds={Array []}

View file

@ -19,6 +19,7 @@ import {getLastPostIndex} from '@mm-redux/utils/post_list';
import tracker from '@utils/time_tracker';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import telemetry from '@telemetry';
import {CHANNEL} from '@constants/screen';
let ChannelIntro = null;
let LoadMorePosts = null;
@ -209,6 +210,7 @@ export default class ChannelPostList extends PureComponent {
scrollViewNativeID={channelId}
loadMorePostsVisible={this.props.loadMorePostsVisible}
showMoreMessagesButton={true}
location={CHANNEL}
/>
);
}

View file

@ -503,6 +503,37 @@ exports[`channelInfo should match snapshot 1`] = `
}
}
/>
<Connect(Bindings)
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</React.Fragment>
</View>
<View
@ -1004,6 +1035,37 @@ exports[`channelInfo should not include NotificationPreference for direct messag
}
}
/>
<Connect(Bindings)
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</React.Fragment>
`;
@ -1363,5 +1425,36 @@ exports[`channelInfo should not include NotificationPreference for direct messag
}
}
/>
<Connect(Bindings)
theme={
Object {
"awayIndicator": "#ffbc42",
"buttonBg": "#166de0",
"buttonColor": "#ffffff",
"centerChannelBg": "#ffffff",
"centerChannelColor": "#3d3c40",
"codeTheme": "github",
"dndIndicator": "#f74343",
"errorTextColor": "#fd5960",
"linkColor": "#2389d7",
"mentionBg": "#ffffff",
"mentionBj": "#ffffff",
"mentionColor": "#145dbf",
"mentionHighlightBg": "#ffe577",
"mentionHighlightLink": "#166de0",
"newMessageSeparator": "#ff8800",
"onlineIndicator": "#06d6a0",
"sidebarBg": "#145dbf",
"sidebarHeaderBg": "#1153ab",
"sidebarHeaderTextColor": "#ffffff",
"sidebarText": "#ffffff",
"sidebarTextActiveBorder": "#579eff",
"sidebarTextActiveColor": "#ffffff",
"sidebarTextHoverBg": "#4578bf",
"sidebarUnreadText": "#ffffff",
"type": "Mattermost",
}
}
/>
</React.Fragment>
`;

View file

@ -0,0 +1,147 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Alert} from 'react-native';
import {intlShape, injectIntl} from 'react-intl';
import Separator from '@screens/channel_info/separator';
import ChannelInfoRow from '../channel_info_row';
import {AppBinding, AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps';
import {Theme} from '@mm-redux/types/preferences';
import {Channel} from '@mm-redux/types/channels';
import {AppCallResponseTypes, AppCallTypes} from '@mm-redux/constants/apps';
import {dismissModal} from '@actions/navigation';
import {ActionResult} from '@mm-redux/types/actions';
import {createCallContext, createCallRequest} from '@utils/apps';
type Props = {
bindings: AppBinding[];
theme: Theme;
currentChannel: Channel;
appsEnabled: boolean;
currentTeamId: string;
actions: {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise<ActionResult>;
}
}
const Bindings: React.FC<Props> = (props: Props) => {
if (!props.appsEnabled) {
return null;
}
const {bindings, ...optionProps} = props;
if (bindings.length === 0) {
return null;
}
const options = bindings.map((b) => (
<Option
key={b.app_id + b.location}
binding={b}
{...optionProps}
/>
));
return (
<>
{options}
</>
);
};
export default Bindings;
type OptionProps = {
binding: AppBinding;
theme: Theme;
currentChannel: Channel;
intl: typeof intlShape;
currentTeamId: string;
actions: {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise<ActionResult>;
},
}
const Option = injectIntl((props: OptionProps) => {
const onPress = async () => {
if (!binding.call) {
return;
}
const context = createCallContext(
binding.app_id,
binding.location,
props.currentChannel.id,
props.currentChannel.team_id || props.currentTeamId,
);
const call = createCallRequest(
binding.call,
context,
);
const res = await props.actions.doAppCall(call, AppCallTypes.SUBMIT, props.intl);
if (res.error) {
const errorResponse = res.error;
const title = props.intl.formatMessage({
id: 'mobile.general.error.title',
defaultMessage: 'Error',
});
const errorMessage = errorResponse.error || props.intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
Alert.alert(title, errorMessage);
dismissModal();
return;
}
const callResp = res.data!;
const ephemeral = (message: string) => props.actions.sendEphemeralPost(message, props.currentChannel.id);
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
ephemeral(callResp.markdown);
}
break;
case AppCallResponseTypes.NAVIGATE:
case AppCallResponseTypes.FORM:
break;
default: {
const title = props.intl.formatMessage({
id: 'mobile.general.error.title',
defaultMessage: 'Error',
});
const errMessage = props.intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
});
Alert.alert(title, errMessage);
}
}
dismissModal();
};
const {binding, theme} = props;
if (!binding.label) {
return null;
}
return (
<>
<Separator theme={theme}/>
<ChannelInfoRow
action={onPress}
defaultMessage={binding.label}
theme={theme}
textId={binding.app_id + binding.location}
image={binding.icon ? {uri: binding.icon} : null}
/>
</>
);
});

View file

@ -0,0 +1,48 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux';
import {getAppsBindings} from '@mm-redux/selectors/entities/apps';
import {AppBindingLocations} from '@mm-redux/constants/apps';
import {getCurrentChannel} from '@mm-redux/selectors/entities/channels';
import {GlobalState} from '@mm-redux/types/store';
import {ActionResult, GenericAction, ActionFunc} from '@mm-redux/types/actions';
import {AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps';
import {appsEnabled} from '@utils/apps';
import {doAppCall} from '@actions/apps';
import Bindings from './bindings';
import {sendEphemeralPost} from '@actions/views/post';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
function mapStateToProps(state: GlobalState) {
const apps = appsEnabled(state);
const currentChannel = getCurrentChannel(state) || {};
const bindings = apps ? getAppsBindings(state, AppBindingLocations.CHANNEL_HEADER_ICON) : [];
return {
bindings,
currentChannel,
appsEnabled: apps,
currentTeamId: getCurrentTeamId(state),
};
}
type Actions = {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise<ActionResult>;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
doAppCall,
sendEphemeralPost,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps, null, {forwardRef: true})(Bindings);

View file

@ -30,6 +30,7 @@ import ManageMembers from './manage_members';
import Mute from './mute';
import Pinned from './pinned';
import Separator from './separator';
import Bindings from './bindings';
export default class ChannelInfo extends PureComponent {
static propTypes = {
@ -158,6 +159,9 @@ export default class ChannelInfo extends PureComponent {
testID='channel_info.edit_channel.action'
theme={theme}
/>
<Bindings
theme={theme}
/>
</>
);
};

View file

@ -14,6 +14,8 @@ import {
import CompassIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import FastImage from 'react-native-fast-image';
import {isValidUrl} from '@utils/url';
function createTouchableComponent(children, action) {
return (
@ -41,6 +43,13 @@ function channelInfoRow(props) {
color={iconColor || changeOpacity(theme.centerChannelColor, 0.64)}
/>
);
} else if (image.uri) {
iconElement = isValidUrl(image.uri) && (
<FastImage
source={image}
style={{width: 24, height: 24}}
/>
);
} else {
iconElement = (
<Image
@ -104,7 +113,10 @@ channelInfoRow.propTypes = {
]),
icon: PropTypes.string,
iconColor: PropTypes.string,
image: PropTypes.number,
image: PropTypes.oneOfType([
PropTypes.number,
PropTypes.object,
]),
imageTintColor: PropTypes.string,
isLandscape: PropTypes.bool,
rightArrow: PropTypes.bool,

View file

@ -46,6 +46,12 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case 'AdvancedSettings':
screen = require('@screens/settings/advanced_settings').default;
break;
case 'AppForm':
screen = require('@screens/apps_form').default;
break;
case 'AppSelectorScreen':
screen = require('@screens/app_selector_screen').default;
break;
case 'ChannelAddMembers':
screen = require('@screens/channel_add_members').default;
break;

View file

@ -32,6 +32,7 @@ import {getLastPostIndex} from '@mm-redux/utils/post_list';
import {privateChannelJoinPrompt} from '@utils/channels';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {PERMALINK} from '@constants/screen';
Animatable.initializeRegistryWithDefinitions({
growOut: {
@ -378,6 +379,7 @@ export default class Permalink extends PureComponent {
currentUserId={currentUserId}
lastViewedAt={0}
highlightPinnedOrFlagged={false}
location={PERMALINK}
/>
);
}

View file

@ -11,8 +11,8 @@ exports[`PostOptions should match snapshot, no option for system message to user
>
<Connect(SlideUpPanel)
allowStayMiddle={false}
initialPosition={322}
marginFromTop={278}
initialPosition={372}
marginFromTop={228}
onRequestClose={[Function]}
theme={
Object {
@ -192,6 +192,18 @@ exports[`PostOptions should match snapshot, no option for system message to user
}
}
/>
<Connect(Bindings)
closeWithAnimation={[Function]}
post={
Object {
"channel_id": "channel_id",
"id": "post_id",
"is_pinned": false,
"message": "message",
"root_id": "root_id",
}
}
/>
</Connect(SlideUpPanel)>
</View>
`;
@ -207,8 +219,8 @@ exports[`PostOptions should match snapshot, showing Delete option only for syste
>
<Connect(SlideUpPanel)
allowStayMiddle={false}
initialPosition={372}
marginFromTop={228}
initialPosition={422}
marginFromTop={178}
onRequestClose={[Function]}
theme={
Object {
@ -424,6 +436,18 @@ exports[`PostOptions should match snapshot, showing Delete option only for syste
}
}
/>
<Connect(Bindings)
closeWithAnimation={[Function]}
post={
Object {
"channel_id": "channel_id",
"id": "post_id",
"is_pinned": false,
"message": "message",
"root_id": "root_id",
}
}
/>
</Connect(SlideUpPanel)>
</View>
`;
@ -439,8 +463,8 @@ exports[`PostOptions should match snapshot, showing all possible options 1`] = `
>
<Connect(SlideUpPanel)
allowStayMiddle={false}
initialPosition={372}
marginFromTop={228}
initialPosition={422}
marginFromTop={178}
onRequestClose={[Function]}
theme={
Object {
@ -656,6 +680,18 @@ exports[`PostOptions should match snapshot, showing all possible options 1`] = `
}
}
/>
<Connect(Bindings)
closeWithAnimation={[Function]}
post={
Object {
"channel_id": "channel_id",
"id": "post_id",
"is_pinned": false,
"message": "message",
"root_id": "root_id",
}
}
/>
</Connect(SlideUpPanel)>
</View>
`;

View file

@ -0,0 +1,159 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Alert} from 'react-native';
import {intlShape, injectIntl} from 'react-intl';
import {isSystemMessage} from '@mm-redux/utils/post_utils';
import PostOption from '../post_option';
import {AppBinding, AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps';
import {Theme} from '@mm-redux/types/preferences';
import {Post} from '@mm-redux/types/posts';
import {UserProfile} from '@mm-redux/types/users';
import {AppCallResponseTypes, AppCallTypes, AppExpandLevels} from '@mm-redux/constants/apps';
import {ActionResult} from '@mm-redux/types/actions';
import {createCallContext, createCallRequest} from '@utils/apps';
type Props = {
bindings: AppBinding[],
theme: Theme,
post: Post,
currentUser: UserProfile,
teamID: string,
closeWithAnimation: () => void,
appsEnabled: boolean,
actions: {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise<ActionResult>;
}
}
const Bindings = (props: Props) => {
if (!props.appsEnabled) {
return null;
}
const {bindings, post, ...optionProps} = props;
if (bindings.length === 0) {
return null;
}
if (isSystemMessage(post)) {
return null;
}
const options = bindings.map((b) => (
<Option
key={b.app_id + b.location}
binding={b}
post={post}
{...optionProps}
/>
));
return (
<>
{options}
</>
);
};
export default Bindings;
type OptionProps = {
binding: AppBinding,
theme: Theme,
post: Post,
currentUser: UserProfile,
teamID: string,
closeWithAnimation: () => void,
intl: typeof intlShape,
actions: {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise<ActionResult>;
},
}
const Option = injectIntl((props: OptionProps) => {
const onPress = async () => {
const {closeWithAnimation, post, teamID} = props;
if (!binding.call) {
return;
}
const context = createCallContext(
binding.app_id,
binding.location,
post.channel_id,
teamID,
post.id,
post.root_id,
);
const call = createCallRequest(
binding.call,
context,
{
post: AppExpandLevels.ALL,
},
);
const res = await props.actions?.doAppCall(call, AppCallTypes.SUBMIT, props.intl);
if (res.error) {
const errorResponse = res.error;
const title = props.intl.formatMessage({
id: 'mobile.general.error.title',
defaultMessage: 'Error',
});
const errorMessage = errorResponse.error || props.intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
Alert.alert(title, errorMessage);
closeWithAnimation();
return;
}
const callResp = (res as {data: AppCallResponse}).data;
const ephemeral = (message: string) => props.actions.sendEphemeralPost(message, props.post.channel_id, props.post.root_id);
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
ephemeral(callResp.markdown);
}
break;
case AppCallResponseTypes.NAVIGATE:
case AppCallResponseTypes.FORM:
break;
default: {
const title = props.intl.formatMessage({
id: 'mobile.general.error.title',
defaultMessage: 'Error',
});
const errMessage = props.intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
});
Alert.alert(title, errMessage);
}
}
closeWithAnimation();
};
const {binding, theme} = props;
if (!binding.label) {
return null;
}
return (
<PostOption
icon={{uri: binding.icon}}
text={binding.label}
onPress={onPress}
theme={theme}
/>
);
});

View file

@ -0,0 +1,58 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {GlobalState} from '@mm-redux/types/store';
import {AppCallRequest, AppCallResponse, AppCallType} from '@mm-redux/types/apps';
import {ActionResult, GenericAction, ActionFunc} from '@mm-redux/types/actions';
import {getAppsBindings} from '@mm-redux/selectors/entities/apps';
import {AppBindingLocations} from '@mm-redux/constants/apps';
import {getCurrentUser} from '@mm-redux/selectors/entities/users';
import {doAppCall} from '@actions/apps';
import {appsEnabled} from '@utils/apps';
import Bindings from './bindings';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import {sendEphemeralPost} from '@actions/views/post';
import {Post} from '@mm-redux/types/posts';
import {getChannel} from '@mm-redux/selectors/entities/channels';
type OwnProps = {
post: Post;
}
function mapStateToProps(state: GlobalState, props: OwnProps) {
const apps = appsEnabled(state);
const bindings = apps ? getAppsBindings(state, AppBindingLocations.POST_MENU_ITEM) : [];
const currentUser = getCurrentUser(state);
const teamID = getChannel(state, props.post.channel_id)?.team_id || getCurrentTeamId(state);
return {
theme: getTheme(state),
bindings,
currentUser,
teamID,
appsEnabled: apps,
};
}
type Actions = {
doAppCall: (call: AppCallRequest, type: AppCallType, intl: any) => Promise<{data?: AppCallResponse, error?: AppCallResponse}>;
sendEphemeralPost: (message: any, channelId?: string, parentId?: string) => Promise<ActionResult>;
}
function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
return {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
doAppCall,
sendEphemeralPost,
}, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps, null, {forwardRef: true})(Bindings);

View file

@ -6,7 +6,7 @@ import {connect} from 'react-redux';
import {addReaction} from '@actions/views/emoji';
import {MAX_ALLOWED_REACTIONS} from '@constants/emoji';
import {THREAD} from '@constants/screen';
import {THREAD, CHANNEL} from '@constants/screen';
import {
deletePost,
flagPost,
@ -56,6 +56,7 @@ export function makeMapStateToProps() {
let {canDelete} = ownProps;
let canFlag = true;
let canPin = true;
let showAppOptions = true;
let canPost = true;
if (isMinimumServerVersion(serverVersion, 5, 22)) {
@ -83,6 +84,10 @@ export function makeMapStateToProps() {
canReply = false;
}
if (ownProps.location !== CHANNEL) {
showAppOptions = false;
}
if (channelIsArchived || ownProps.channelIsReadOnly) {
canAddReaction = false;
canReply = false;
@ -139,6 +144,7 @@ export function makeMapStateToProps() {
canMarkAsUnread,
currentTeamUrl: getCurrentTeamUrl(state),
currentUserId,
showAppOptions,
theme: getTheme(state),
};
};

View file

@ -14,12 +14,17 @@ import {
import CompassIcon from '@components/compass_icon';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {preventDoubleTap} from '@utils/tap';
import FastImage from 'react-native-fast-image';
import {isValidUrl} from '@utils/url';
export default class PostOption extends PureComponent {
static propTypes = {
testID: PropTypes.string,
destructive: PropTypes.bool,
icon: PropTypes.string.isRequired,
icon: PropTypes.oneOfType([
PropTypes.string,
PropTypes.object,
]).isRequired,
onPress: PropTypes.func.isRequired,
text: PropTypes.string.isRequired,
theme: PropTypes.object.isRequired,
@ -50,6 +55,31 @@ export default class PostOption extends PureComponent {
},
});
const imageStyle = [style.icon, destructive ? style.destructive : null];
let image;
let iconStyle = [style.iconContainer];
if (typeof icon === 'object') {
if (icon.uri) {
imageStyle.push({width: 24, height: 24});
image = isValidUrl(icon.uri) && (
<FastImage
source={icon}
style={imageStyle}
/>
);
} else {
iconStyle = [style.noIconContainer];
}
} else {
image = (
<CompassIcon
name={icon}
size={24}
style={[style.icon, destructive ? style.destructive : null]}
/>
);
}
return (
<View
testID={testID}
@ -61,12 +91,8 @@ export default class PostOption extends PureComponent {
style={style.row}
>
<View style={style.row}>
<View style={[style.iconContainer]}>
<CompassIcon
name={icon}
size={24}
style={[style.icon, destructive ? style.destructive : null]}
/>
<View style={iconStyle}>
{image}
</View>
<View style={style.textContainer}>
<Text style={[style.text, destructive ? style.destructive : null]}>
@ -100,6 +126,10 @@ const getStyleSheet = makeStyleSheetFromTheme((theme) => {
justifyContent: 'center',
width: 60,
},
noIconContainer: {
height: 50,
width: 18,
},
icon: {
color: changeOpacity(theme.centerChannelColor, 0.64),
},

View file

@ -20,6 +20,7 @@ import {preventDoubleTap} from '@utils/tap';
import PostOption from './post_option';
import {OPTION_HEIGHT, getInitialPosition} from './post_options_utils';
import Bindings from './bindings';
export default class PostOptions extends PureComponent {
static propTypes = {
@ -43,6 +44,7 @@ export default class PostOptions extends PureComponent {
canEdit: PropTypes.bool,
canMarkAsUnread: PropTypes.bool, //#backwards-compatibility:5.18v
canEditUntil: PropTypes.number.isRequired,
showAppOptions: PropTypes.bool.isRequired,
currentTeamUrl: PropTypes.string.isRequired,
currentUserId: PropTypes.string.isRequired,
deviceHeight: PropTypes.number.isRequired,
@ -234,6 +236,21 @@ export default class PostOptions extends PureComponent {
return null;
};
getAppsOptions = () => {
if (!this.props.showAppOptions) {
return null;
}
const {post} = this.props;
return (
<Bindings
key='bindings'
post={post}
closeWithAnimation={this.closeWithAnimation}
/>
);
}
getPostOptions = () => {
const actions = [
this.getReplyOption(),
@ -244,6 +261,7 @@ export default class PostOptions extends PureComponent {
this.getPinOption(),
this.getEditOption(),
this.getDeleteOption(),
this.getAppsOptions(),
];
return actions.filter((a) => a !== null);

View file

@ -50,6 +50,7 @@ describe('PostOptions', () => {
managedConfig: {},
post,
showAddReaction: true,
showAppOptions: true,
theme: Preferences.THEMES.default,
};

View file

@ -4,6 +4,7 @@ exports[`SearchResultPost should match snapshot 1`] = `
<Connect(Post)
highlightPinnedOrFlagged={false}
isSearchResult={true}
location="search"
managedConfig={Object {}}
onHashtagPress={[MockFunction]}
onPermalinkPress={[MockFunction]}

View file

@ -5,6 +5,7 @@ import React, {PureComponent} from 'react';
import PropTypes from 'prop-types';
import Post from '@components/post';
import {SEARCH} from '@constants/screen';
export default class SearchResultPost extends PureComponent {
static propTypes = {
@ -50,6 +51,7 @@ export default class SearchResultPost extends PureComponent {
isSearchResult={true}
showAddReaction={false}
showFullDate={this.props.showFullDate}
location={SEARCH}
/>
);
}

443
app/utils/apps.test.ts Normal file
View file

@ -0,0 +1,443 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AppBinding, AppCall} from '@mm-redux/types/apps';
import {fillAndTrimBindingsInformation} from './apps';
describe('Apps Utils', () => {
describe('fillAndTrimBindingsInformation', () => {
test('Apps IDs, and Calls propagate down, and locations get formed', () => {
const inBinding: AppBinding = {
app_id: 'id',
location: 'loc1',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc2',
bindings: [
{
location: 'loc3',
} as AppBinding,
{
location: 'loc4',
} as AppBinding,
],
} as AppBinding,
{
location: 'loc5',
} as AppBinding,
],
} as AppBinding;
const outBinding: AppBinding = {
app_id: 'id',
location: 'loc1',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc1/loc2',
app_id: 'id',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc1/loc2/loc3',
app_id: 'id',
call: {
path: 'url',
} as AppCall,
} as AppBinding,
{
location: 'loc1/loc2/loc4',
app_id: 'id',
call: {
path: 'url',
} as AppCall,
} as AppBinding,
],
} as AppBinding,
{
location: 'loc1/loc5',
app_id: 'id',
call: {
path: 'url',
} as AppCall,
},
],
} as AppBinding;
fillAndTrimBindingsInformation(inBinding);
expect(inBinding).toEqual(outBinding);
});
test('Do not overwrite calls nor ids on the way down.', () => {
const inBinding: AppBinding = {
app_id: 'id',
location: 'loc1',
call: {
path: 'url',
} as AppCall,
bindings: [
{
app_id: 'id2',
location: 'loc2',
bindings: [
{
location: 'loc3',
} as AppBinding,
{
location: 'loc4',
} as AppBinding,
],
} as AppBinding,
{
call: {
path: 'url2',
} as AppCall,
location: 'loc5',
} as AppBinding,
],
} as AppBinding;
const outBinding: AppBinding = {
app_id: 'id',
location: 'loc1',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc1/loc2',
app_id: 'id2',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc1/loc2/loc3',
app_id: 'id2',
call: {
path: 'url',
} as AppCall,
} as AppBinding,
{
location: 'loc1/loc2/loc4',
app_id: 'id2',
call: {
path: 'url',
} as AppCall,
} as AppBinding,
],
} as AppBinding,
{
location: 'loc1/loc5',
app_id: 'id',
call: {
path: 'url2',
} as AppCall,
},
],
} as AppBinding;
fillAndTrimBindingsInformation(inBinding);
expect(inBinding).toEqual(outBinding);
});
test('Populate ids on the way up.', () => {
const inBinding: AppBinding = {
location: 'loc1',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc2',
bindings: [
{
app_id: 'id1',
location: 'loc3',
} as AppBinding,
{
app_id: 'id2',
location: 'loc4',
} as AppBinding,
],
} as AppBinding,
{
app_id: 'id3',
location: 'loc5',
} as AppBinding,
],
} as AppBinding;
const outBinding: AppBinding = {
app_id: 'id1',
location: 'loc1',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc1/loc2',
app_id: 'id1',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc1/loc2/loc3',
app_id: 'id1',
call: {
path: 'url',
} as AppCall,
} as AppBinding,
{
location: 'loc1/loc2/loc4',
app_id: 'id2',
call: {
path: 'url',
} as AppCall,
} as AppBinding,
],
} as AppBinding,
{
location: 'loc1/loc5',
app_id: 'id3',
call: {
path: 'url',
} as AppCall,
},
],
} as AppBinding;
fillAndTrimBindingsInformation(inBinding);
expect(inBinding).toEqual(outBinding);
});
test('Populate calls on the way up.', () => {
const inBinding: AppBinding = {
location: 'loc1',
bindings: [
{
location: 'loc2',
bindings: [
{
app_id: 'id1',
location: 'loc3',
call: {
path: 'url1',
} as AppCall,
} as AppBinding,
{
app_id: 'id2',
location: 'loc4',
call: {
path: 'url2',
} as AppCall,
} as AppBinding,
],
} as AppBinding,
{
app_id: 'id3',
location: 'loc5',
call: {
path: 'url3',
} as AppCall,
} as AppBinding,
],
} as AppBinding;
const outBinding: AppBinding = {
app_id: 'id1',
location: 'loc1',
call: {
path: 'url1',
} as AppCall,
bindings: [
{
location: 'loc1/loc2',
app_id: 'id1',
call: {
path: 'url1',
} as AppCall,
bindings: [
{
location: 'loc1/loc2/loc3',
app_id: 'id1',
call: {
path: 'url1',
} as AppCall,
} as AppBinding,
{
location: 'loc1/loc2/loc4',
app_id: 'id2',
call: {
path: 'url2',
} as AppCall,
} as AppBinding,
],
} as AppBinding,
{
location: 'loc1/loc5',
app_id: 'id3',
call: {
path: 'url3',
} as AppCall,
},
],
} as AppBinding;
fillAndTrimBindingsInformation(inBinding);
expect(inBinding).toEqual(outBinding);
});
test('Trim branches without app_id.', () => {
const inBinding: AppBinding = {
location: 'loc1',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc2',
bindings: [
{
location: 'loc3',
} as AppBinding,
{
app_id: 'id',
location: 'loc4',
} as AppBinding,
],
} as AppBinding,
{
location: 'loc5',
} as AppBinding,
],
} as AppBinding;
const outBinding: AppBinding = {
app_id: 'id',
location: 'loc1',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc1/loc2',
app_id: 'id',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc1/loc2/loc4',
app_id: 'id',
call: {
path: 'url',
} as AppCall,
} as AppBinding,
],
} as AppBinding,
],
} as AppBinding;
fillAndTrimBindingsInformation(inBinding);
expect(inBinding).toEqual(outBinding);
});
test('Trim branches without call.', () => {
const inBinding: AppBinding = {
location: 'loc1',
app_id: 'id',
bindings: [
{
location: 'loc2',
bindings: [
{
location: 'loc3',
} as AppBinding,
{
location: 'loc4',
call: {
path: 'url',
} as AppCall,
} as AppBinding,
],
} as AppBinding,
{
location: 'loc5',
} as AppBinding,
],
} as AppBinding;
const outBinding: AppBinding = {
app_id: 'id',
location: 'loc1',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc1/loc2',
app_id: 'id',
call: {
path: 'url',
} as AppCall,
bindings: [
{
location: 'loc1/loc2/loc4',
app_id: 'id',
call: {
path: 'url',
} as AppCall,
} as AppBinding,
],
} as AppBinding,
],
} as AppBinding;
fillAndTrimBindingsInformation(inBinding);
expect(inBinding).toEqual(outBinding);
});
test('Trim mixed invalid branches.', () => {
const inBinding: AppBinding = {
location: 'loc1',
bindings: [
{
location: 'loc2',
bindings: [
{
location: 'loc3',
} as AppBinding,
{
location: 'loc4',
call: {
path: 'url',
} as AppCall,
} as AppBinding,
],
} as AppBinding,
{
app_id: 'id',
location: 'loc5',
} as AppBinding,
],
} as AppBinding;
const outBinding: AppBinding = {
location: 'loc1',
bindings: [] as AppBinding[],
} as AppBinding;
fillAndTrimBindingsInformation(inBinding);
expect(inBinding).toEqual(outBinding);
});
});
});

181
app/utils/apps.ts Normal file
View file

@ -0,0 +1,181 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AppBindingLocations, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {AppBinding, AppCall, AppCallRequest, AppCallValues, AppContext, AppExpand} from '@mm-redux/types/apps';
import {getConfig} from '@mm-redux/selectors/entities/general';
import {Config} from '@mm-redux/types/config';
import {GlobalState} from '@mm-redux/types/store';
export function appsEnabled(state: GlobalState) { // eslint-disable-line @typescript-eslint/no-unused-vars
const enabled = getConfig(state)?.['FeatureFlagAppsEnabled' as keyof Partial<Config>];
return enabled === 'true';
}
export function copyAndFillBindings(binding?: AppBinding): AppBinding | undefined {
if (!binding) {
return undefined;
}
const copy = JSON.parse(JSON.stringify(binding));
fillAndTrimBindingsInformation(copy);
return copy;
}
// fillAndTrimBindingsInformation does:
// - Build the location (e.g. channel_header/binding)
// - Inherit app calls
// - Inherit app ids
// - Trim invalid bindings (do not have an app call or app id at the leaf)
export function fillAndTrimBindingsInformation(binding?: AppBinding) {
if (!binding) {
return;
}
binding.bindings?.forEach((b) => {
// Propagate id down if not defined
if (!b.app_id) {
b.app_id = binding.app_id;
}
// Compose location
b.location = binding.location + '/' + b.location;
// Propagate call down if not defined
if (!b.call) {
b.call = binding.call;
}
fillAndTrimBindingsInformation(b);
});
// Trim branches without app_id
if (!binding.app_id) {
binding.bindings = binding.bindings?.filter((v) => v.app_id);
}
// Trim branches without calls
if (!binding.call) {
binding.bindings = binding.bindings?.filter((v) => v.call);
}
// Pull up app_id if needed
if (binding.bindings?.length && !binding.app_id) {
binding.app_id = binding.bindings[0].app_id;
}
// Pull up call if needed
if (binding.bindings?.length && !binding.call) {
binding.call = binding.bindings[0].call;
}
}
export function validateBindings(binding?: AppBinding) {
filterInvalidChannelHeaderBindings(binding);
filterInvalidCommands(binding);
filterInvalidPostMenuBindings(binding);
binding?.bindings?.forEach(fillAndTrimBindingsInformation);
}
// filterInvalidCommands remove commands without a label
function filterInvalidCommands(binding?: AppBinding) {
if (!binding) {
return;
}
const isValidCommand = (b: AppBinding): boolean => {
return Boolean(b.label);
};
const validateCommand = (b: AppBinding) => {
b.bindings = b.bindings?.filter(isValidCommand);
b.bindings?.forEach(validateCommand);
};
binding.bindings?.filter((b) => b.location === AppBindingLocations.COMMAND).forEach(validateCommand);
}
// filterInvalidChannelHeaderBindings remove bindings
// without a label.
function filterInvalidChannelHeaderBindings(binding?: AppBinding) {
if (!binding) {
return;
}
const isValidChannelHeaderBindings = (b: AppBinding): boolean => {
return Boolean(b.label);
};
const validateChannelHeaderBinding = (b: AppBinding) => {
b.bindings = b.bindings?.filter(isValidChannelHeaderBindings);
b.bindings?.forEach(validateChannelHeaderBinding);
};
binding.bindings?.filter((b) => b.location === AppBindingLocations.CHANNEL_HEADER_ICON).forEach(validateChannelHeaderBinding);
}
// filterInvalidPostMenuBindings remove bindings
// without a label.
function filterInvalidPostMenuBindings(binding?: AppBinding) {
if (!binding) {
return;
}
const isValidPostMenuBinding = (b: AppBinding): boolean => {
return Boolean(b.label);
};
const validatePostMenuBinding = (b: AppBinding) => {
b.bindings = b.bindings?.filter(isValidPostMenuBinding);
b.bindings?.forEach(validatePostMenuBinding);
};
binding.bindings?.filter((b) => b.location === AppBindingLocations.POST_MENU_ITEM).forEach(validatePostMenuBinding);
}
export function createCallContext(
appID: string,
location?: string,
channelID?: string,
teamID?: string,
postID?: string,
rootID?: string,
): AppContext {
return {
app_id: appID,
location,
channel_id: channelID,
team_id: teamID,
post_id: postID,
root_id: rootID,
};
}
export function createCallRequest(
call: AppCall,
context: AppContext,
defaultExpand: AppExpand = {},
values?: AppCallValues,
rawCommand?: string,
query?: string,
selectedField?: string,
): AppCallRequest {
return {
...call,
context,
values,
expand: {
...defaultExpand,
...call.expand,
},
raw_command: rawCommand,
query,
selected_field: selectedField,
};
}
export const makeCallErrorResponse = (errMessage: string) => {
return {
type: AppCallResponseTypes.ERROR,
error: errMessage,
};
};

View file

@ -12,6 +12,41 @@
"about.title": "About {appTitle}",
"announcment_banner.dont_show_again": "Don't show again",
"api.channel.add_member.added": "{addedUsername} added to the channel by {username}.",
"apps.error": "Error: {error}",
"apps.error.command.field_missing": "Required fields missing: `{fieldName}`.",
"apps.error.command.unknown_channel": "Unknown channel for field `{fieldName}`: `{option}`.",
"apps.error.command.unknown_option": "Unknown option for field `{fieldName}`: `{option}`.",
"apps.error.command.unknown_user": "Unknown user for field `{fieldName}`: `{option}`.",
"apps.error.form.no_call": "`call` is not defined.",
"apps.error.form.no_form": "`form` is not defined.",
"apps.error.form.no_lookup_call": "performLookupCall props.call is not defined",
"apps.error.form.refresh": "There has been an error fetching the select fields. Contact the app developer. Details: {details}",
"apps.error.form.refresh_no_refresh": "Called refresh on no refresh field.",
"apps.error.form.submit.pretext": "There has been an error submitting the modal. Contact the app developer. Details: {details}",
"apps.error.lookup.error_preparing_request": "Error preparing lookup request.",
"apps.error.parser": "Parsing error: {error}.\n```\n{command}\n{space}^\n```",
"apps.error.parser.empty_value": "empty values are not allowed",
"apps.error.parser.missing_field_value": "Field value is missing.",
"apps.error.parser.missing_quote": "Matching double quote expected before end of input.",
"apps.error.parser.missing_tick": "Matching tick quote expected before end of input.",
"apps.error.parser.multiple_equal": "Multiple `=` signs are not allowed.",
"apps.error.parser.no_argument_pos_x": "Command does not accept {positionX} positional arguments.",
"apps.error.parser.no_bindings": "No command bindings.",
"apps.error.parser.no_match": "`{command}`: No matching command found in this workspace.",
"apps.error.parser.no_slash_start": "Command must start with a `/`.",
"apps.error.parser.unexpected_error": "Unexpected error.",
"apps.error.parser.unexpected_flag": "Command does not accept flag `{flagName}`.",
"apps.error.parser.unexpected_state": "Unreachable: Unexpected state in matchBinding: `{state}`.",
"apps.error.parser.unexpected_whitespace": "Unreachable: Unexpected whitespace.",
"apps.error.responses.form.no_form": "Response type is `form`, but no form was included in response.",
"apps.error.responses.navigate.no_submit": "Response type is `navigate`, but the call was not a submission.",
"apps.error.responses.navigate.no_url": "Response type is `navigate`, but no url was included in response.",
"apps.error.responses.unexpected_error": "Received an unexpected error.",
"apps.error.responses.unexpected_type": "App response type was not expected. Response type: {type}.",
"apps.error.responses.unknown_type": "App response type not supported. Response type: {type}.",
"apps.error.unknown": "Unknown error occurred.",
"apps.suggestion.no_dynamic": "No data was returned for dynamic suggestions",
"apps.suggestion.no_static": "No matching options.",
"archivedChannelMessage": "You are viewing an **archived channel**. New messages cannot be posted.",
"center_panel.archived.closeChannel": "Close Channel",
"channel_header.addMembers": "Add Members",
@ -284,6 +319,7 @@
"mobile.flagged_posts.empty_description": "Saved messages are only visible to you. Mark messages for follow-up or save something for later by long-pressing a message and choosing Save from the menu.",
"mobile.flagged_posts.empty_title": "No Saved messages yet",
"mobile.gallery.title": "{index} of {total}",
"mobile.general.error.title": "Error",
"mobile.help.title": "Help",
"mobile.intro_messages.default_message": "This is the first channel teammates see when they sign up - use it for posting updates everyone needs to know.",
"mobile.intro_messages.default_welcome": "Welcome to {name}!",

View file

@ -0,0 +1,3 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
declare module 'react-native-button';