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:
parent
78c05270ba
commit
11c8454ee2
91 changed files with 9303 additions and 137 deletions
116
app/actions/apps.ts
Normal file
116
app/actions/apps.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
};
|
||||
}
|
||||
95
app/actions/views/command.ts
Normal file
95
app/actions/views/command.ts
Normal 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};
|
||||
};
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
18
app/actions/websocket/apps.ts
Normal file
18
app/actions/websocket/apps.ts
Normal 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};
|
||||
};
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
`;
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
};
|
||||
|
|
@ -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',
|
||||
}],
|
||||
},
|
||||
}],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
48
app/components/embedded_bindings/button_binding/index.ts
Normal file
48
app/components/embedded_bindings/button_binding/index.ts
Normal 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);
|
||||
115
app/components/embedded_bindings/embed_text.tsx
Normal file
115
app/components/embedded_bindings/embed_text.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
69
app/components/embedded_bindings/embed_title.tsx
Normal file
69
app/components/embedded_bindings/embed_title.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
85
app/components/embedded_bindings/embedded_binding.tsx
Normal file
85
app/components/embedded_bindings/embedded_binding.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
52
app/components/embedded_bindings/embedded_sub_bindings.tsx
Normal file
52
app/components/embedded_bindings/embedded_sub_bindings.tsx
Normal 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;
|
||||
}
|
||||
57
app/components/embedded_bindings/index.tsx
Normal file
57
app/components/embedded_bindings/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
app/components/embedded_bindings/menu_binding/index.ts
Normal file
45
app/components/embedded_bindings/menu_binding/index.ts
Normal 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);
|
||||
137
app/components/embedded_bindings/menu_binding/menu_binding.tsx
Normal file
137
app/components/embedded_bindings/menu_binding/menu_binding.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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|<3)|(<\/3|<\/3)|(:[`'’]-?\(|:'\(|:'\())(?=$|\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 = [];
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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|<3)|(<\/3|<\/3)|(:[`'’]-?\(|:'\(|:'\())(?=$|\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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
7
app/mm-redux/action_types/apps.ts
Normal file
7
app/mm-redux/action_types/apps.ts
Normal 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,
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
15
app/mm-redux/actions/apps.ts
Normal file
15
app/mm-redux/actions/apps.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
46
app/mm-redux/constants/apps.ts
Normal file
46
app/mm-redux/constants/apps.ts
Normal 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',
|
||||
};
|
||||
340
app/mm-redux/reducers/entities/__snapshots__/apps.test.js.snap
Normal file
340
app/mm-redux/reducers/entities/__snapshots__/apps.test.js.snap
Normal 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",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
360
app/mm-redux/reducers/entities/apps.test.js
Normal file
360
app/mm-redux/reducers/entities/apps.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
23
app/mm-redux/reducers/entities/apps.ts
Normal file
23
app/mm-redux/reducers/entities/apps.ts
Normal 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);
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
16
app/mm-redux/selectors/entities/apps.ts
Normal file
16
app/mm-redux/selectors/entities/apps.ts
Normal 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
205
app/mm-redux/types/apps.ts
Normal 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[];
|
||||
}
|
||||
30
app/mm-redux/types/integration_actions.ts
Normal file
30
app/mm-redux/types/integration_actions.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
127
app/screens/app_selector_screen/app_selector_screen.test.tsx
Normal file
127
app/screens/app_selector_screen/app_selector_screen.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
465
app/screens/app_selector_screen/app_selector_screen.tsx
Normal file
465
app/screens/app_selector_screen/app_selector_screen.tsx
Normal 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));
|
||||
});
|
||||
40
app/screens/app_selector_screen/index.ts
Normal file
40
app/screens/app_selector_screen/index.ts
Normal 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);
|
||||
|
|
@ -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>
|
||||
`;
|
||||
280
app/screens/apps_form/app_form_selector/app_form_selector.tsx
Normal file
280
app/screens/apps_form/app_form_selector/app_form_selector.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
18
app/screens/apps_form/app_form_selector/index.ts
Normal file
18
app/screens/apps_form/app_form_selector/index.ts
Normal 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);
|
||||
390
app/screens/apps_form/apps_form_component.tsx
Normal file
390
app/screens/apps_form/apps_form_component.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
241
app/screens/apps_form/apps_form_container.tsx
Normal file
241
app/screens/apps_form/apps_form_container.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
150
app/screens/apps_form/apps_form_field.tsx
Normal file
150
app/screens/apps_form/apps_form_field.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
38
app/screens/apps_form/dialog_introduction_text.test.tsx
Normal file
38
app/screens/apps_form/dialog_introduction_text.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
58
app/screens/apps_form/dialog_introduction_text.tsx
Normal file
58
app/screens/apps_form/dialog_introduction_text.tsx
Normal 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,
|
||||
},
|
||||
};
|
||||
});
|
||||
37
app/screens/apps_form/index.ts
Normal file
37
app/screens/apps_form/index.ts
Normal 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);
|
||||
|
|
@ -27,6 +27,7 @@ exports[`ChannelPostList should match snapshot 1`] = `
|
|||
indicateNewMessages={true}
|
||||
lastPostIndex={-1}
|
||||
loadMorePostsVisible={false}
|
||||
location="channel"
|
||||
onLoadMoreUp={[Function]}
|
||||
onPostPress={[Function]}
|
||||
postIds={Array []}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
147
app/screens/channel_info/bindings/bindings.tsx
Normal file
147
app/screens/channel_info/bindings/bindings.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
48
app/screens/channel_info/bindings/index.ts
Normal file
48
app/screens/channel_info/bindings/index.ts
Normal 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);
|
||||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
`;
|
||||
|
|
|
|||
159
app/screens/post_options/bindings/bindings.tsx
Normal file
159
app/screens/post_options/bindings/bindings.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
58
app/screens/post_options/bindings/index.ts
Normal file
58
app/screens/post_options/bindings/index.ts
Normal 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);
|
||||
|
|
@ -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),
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ describe('PostOptions', () => {
|
|||
managedConfig: {},
|
||||
post,
|
||||
showAddReaction: true,
|
||||
showAppOptions: true,
|
||||
theme: Preferences.THEMES.default,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ exports[`SearchResultPost should match snapshot 1`] = `
|
|||
<Connect(Post)
|
||||
highlightPinnedOrFlagged={false}
|
||||
isSearchResult={true}
|
||||
location="search"
|
||||
managedConfig={Object {}}
|
||||
onHashtagPress={[MockFunction]}
|
||||
onPermalinkPress={[MockFunction]}
|
||||
|
|
|
|||
|
|
@ -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
443
app/utils/apps.test.ts
Normal 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
181
app/utils/apps.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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}!",
|
||||
|
|
|
|||
3
types/modules/react-native-button.d.ts
vendored
Normal file
3
types/modules/react-native-button.d.ts
vendored
Normal 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';
|
||||
Loading…
Reference in a new issue