Add AppsForm and Interactive Dialogs (#6142)

* Add AppsForm and Interactive Dialogs

* Add the missing plumbing for Interactive Dialogs and minor fixes

* Remove widgets subfolder

* Fix paths

* Address feedback

* Address feedback

* i18n extract

* Only set the dialog if we are in the same server
This commit is contained in:
Daniel Espino García
2022-04-28 18:26:21 +02:00
committed by GitHub
parent a2fac160ef
commit e047106bac
44 changed files with 3258 additions and 559 deletions

View File

@@ -5,18 +5,61 @@ import {IntlShape} from 'react-intl';
import {sendEphemeralPost} from '@actions/local/post';
import ClientError from '@client/rest/error';
import CompassIcon from '@components/compass_icon';
import {Screens} from '@constants';
import {AppCallResponseTypes, AppCallTypes} from '@constants/apps';
import {AppCallResponseTypes} from '@constants/apps';
import NetworkManager from '@managers/network_manager';
import {showModal} from '@screens/navigation';
import EphemeralStore from '@store/ephemeral_store';
import {makeCallErrorResponse} from '@utils/apps';
import {cleanForm, createCallRequest, makeCallErrorResponse} from '@utils/apps';
import type {Client} from '@client/rest';
import type PostModel from '@typings/database/models/servers/post';
export async function doAppCall<Res=unknown>(serverUrl: string, call: AppCallRequest, type: AppCallType, intl: IntlShape, theme: Theme) {
export async function handleBindingClick<Res=unknown>(serverUrl: string, binding: AppBinding, context: AppContext, intl: IntlShape): Promise<{data?: AppCallResponse<Res>; error?: AppCallResponse<Res>}> {
// Fetch form
if (binding.form?.source) {
const callRequest = createCallRequest(
binding.form.source,
context,
);
return doAppFetchForm<Res>(serverUrl, callRequest, intl);
}
// Open form
if (binding.form) {
// This should come properly formed, but using preventive checks
if (!binding.form?.submit) {
const errMsg = intl.formatMessage({
id: 'apps.error.malformed_binding',
defaultMessage: 'This binding is not properly formed. Contact the App developer.',
});
return {error: makeCallErrorResponse(errMsg)};
}
const res: AppCallResponse<Res> = {
type: AppCallResponseTypes.FORM,
form: binding.form,
};
return {data: res};
}
// Submit binding
// This should come properly formed, but using preventive checks
if (!binding.submit) {
const errMsg = intl.formatMessage({
id: 'apps.error.malformed_binding',
defaultMessage: 'This binding is not properly formed. Contact the App developer.',
});
return {error: makeCallErrorResponse(errMsg)};
}
const callRequest = createCallRequest(
binding.submit,
context,
);
return doAppSubmit<Res>(serverUrl, callRequest, intl);
}
export async function doAppSubmit<Res=unknown>(serverUrl: string, inCall: AppCallRequest, intl: IntlShape) {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
@@ -25,7 +68,14 @@ export async function doAppCall<Res=unknown>(serverUrl: string, call: AppCallReq
}
try {
const res = await client.executeAppCall(call, type) as AppCallResponse<Res>;
const call: AppCallRequest = {
...inCall,
context: {
...inCall.context,
track_as_submit: true,
},
};
const res = await client.executeAppCall(call, true) as AppCallResponse<Res>;
const responseType = res.type || AppCallResponseTypes.OK;
switch (responseType) {
@@ -34,18 +84,15 @@ export async function doAppCall<Res=unknown>(serverUrl: string, call: AppCallReq
case AppCallResponseTypes.ERROR:
return {error: res};
case AppCallResponseTypes.FORM: {
if (!res.form) {
if (!res.form?.submit) {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.form.no_form',
defaultMessage: 'Response type is `form`, but no form was included in the response.',
defaultMessage: 'Response type is `form`, but no valid form was included in response.',
});
return {error: makeCallErrorResponse(errMsg)};
}
const screen = EphemeralStore.getNavigationTopComponentId();
if (type === AppCallTypes.SUBMIT && screen !== Screens.APP_FORM) {
showAppForm(res.form, call, theme);
}
cleanForm(res.form);
return {data: res};
}
@@ -58,17 +105,6 @@ export async function doAppCall<Res=unknown>(serverUrl: string, call: AppCallReq
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)};
}
// TODO: Add functionality to handle this
// handleGotoLocation(res.navigate_to_url, intl);
return {data: res};
default: {
const errMsg = intl.formatMessage({
@@ -81,7 +117,84 @@ export async function doAppCall<Res=unknown>(serverUrl: string, call: AppCallReq
}
}
} catch (error) {
const errMsg = (error as Error).message || intl.formatMessage({
const errMsg = (error as ClientError).message || intl.formatMessage({
id: 'apps.error.responses.unexpected_error',
defaultMessage: 'Received an unexpected error.',
});
return {error: makeCallErrorResponse(errMsg)};
}
}
export async function doAppFetchForm<Res=unknown>(serverUrl: string, call: AppCallRequest, intl: IntlShape) {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error: makeCallErrorResponse((error as ClientError).message)};
}
try {
const res = await client.executeAppCall(call, false) as AppCallResponse<Res>;
const responseType = res.type || AppCallResponseTypes.OK;
switch (responseType) {
case AppCallResponseTypes.ERROR:
return {error: res};
case AppCallResponseTypes.FORM:
if (!res.form?.submit) {
const errMsg = intl.formatMessage({
id: 'apps.error.responses.form.no_form',
defaultMessage: 'Response type is `form`, but no valid form was included in response.',
});
return {error: makeCallErrorResponse(errMsg)};
}
cleanForm(res.form);
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: any) {
const errMsg = error.message || intl.formatMessage({
id: 'apps.error.responses.unexpected_error',
defaultMessage: 'Received an unexpected error.',
});
return {error: makeCallErrorResponse(errMsg)};
}
}
export async function doAppLookup<Res=unknown>(serverUrl: string, call: AppCallRequest, intl: IntlShape) {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error: makeCallErrorResponse((error as ClientError).message)};
}
try {
const res = await client.executeAppCall(call, false) as AppCallResponse<Res>;
const responseType = res.type || AppCallResponseTypes.OK;
switch (responseType) {
case AppCallResponseTypes.OK:
return {data: res};
case AppCallResponseTypes.ERROR:
return {error: 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: any) {
const errMsg = error.message || intl.formatMessage({
id: 'apps.error.responses.unexpected_error',
defaultMessage: 'Received an unexpected error.',
});
@@ -128,38 +241,3 @@ export function postEphemeralCallResponseForCommandArgs(serverUrl: string, respo
response.app_metadata?.bot_user_id,
);
}
export 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(Screens.APP_FORM, form.title || '', passProps, options);
};

View File

@@ -6,9 +6,9 @@ import {Alert} from 'react-native';
import {showPermalink} from '@actions/remote/permalink';
import {Client} from '@client/rest';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DeepLinkTypes from '@constants/deep_linking';
import DatabaseManager from '@database/manager';
import IntegrationsManager from '@managers/integrations_manager';
import NetworkManager from '@managers/network_manager';
import {getChannelById} from '@queries/servers/channel';
import {getConfig, getCurrentTeamId} from '@queries/servers/system';
@@ -35,7 +35,6 @@ export const executeCommand = async (serverUrl: string, intl: IntlShape, message
return {error: error as ClientErrorProps};
}
// TODO https://mattermost.atlassian.net/browse/MM-41234
// const config = await queryConfig(operator.database)
// if (config.FeatureFlagAppsEnabled) {
// const parser = new AppCommandParser(serverUrl, intl, channelId, rootId);
@@ -72,10 +71,7 @@ export const executeCommand = async (serverUrl: string, intl: IntlShape, message
}
if (data?.trigger_id) { //eslint-disable-line camelcase
operator.handleSystem({
systems: [{id: SYSTEM_IDENTIFIERS.INTEGRATION_TRIGGER_ID, value: data.trigger_id}],
prepareRecordsOnly: false,
});
IntegrationsManager.getManager(serverUrl)?.setTriggerId(data.trigger_id);
}
return {data};

View File

@@ -0,0 +1,62 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
//
import DatabaseManager from '@database/manager';
import IntegrationsMananger from '@managers/integrations_manager';
import NetworkManager from '@managers/network_manager';
import {getCurrentChannelId, getCurrentTeamId} from '@queries/servers/system';
import {forceLogoutIfNecessary} from './session';
import type {Client} from '@client/rest';
export const submitInteractiveDialog = async (serverUrl: string, submission: DialogSubmission) => {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
if (!database) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
submission.channel_id = await getCurrentChannelId(database);
submission.team_id = await getCurrentTeamId(database);
try {
const data = await client.submitInteractiveDialog(submission);
return {data};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const postActionWithCookie = async (serverUrl: string, postId: string, actionId: string, actionCookie: string, selectedOption = '') => {
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const data = await client.doPostActionWithCookie(postId, actionId, actionCookie, selectedOption);
if (data?.trigger_id) {
IntegrationsMananger.getManager(serverUrl)?.setTriggerId(data.trigger_id);
}
return {data};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export const selectAttachmentMenuAction = (serverUrl: string, postId: string, actionId: string, selectedOption: string) => {
return postActionWithCookie(serverUrl, postId, actionId, '', selectedOption);
};

View File

@@ -11,7 +11,7 @@ import {removePost} from '@actions/local/post';
import {addRecentReaction} from '@actions/local/reactions';
import {createThreadFromNewPost} from '@actions/local/thread';
import {ActionType, Events, General, Post, ServerErrors} from '@constants';
import {MM_TABLES, SYSTEM_IDENTIFIERS} from '@constants/database';
import {MM_TABLES} from '@constants/database';
import DatabaseManager from '@database/manager';
import {filterPostsInOrderedArray} from '@helpers/api/post';
import {getNeededAtMentionedUsernames} from '@helpers/api/user';
@@ -619,38 +619,6 @@ export async function fetchPostsAround(serverUrl: string, channelId: string, pos
}
}
export const postActionWithCookie = async (serverUrl: string, postId: string, actionId: string, actionCookie: string, selectedOption = '') => {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
return {error: `${serverUrl} database not found`};
}
let client: Client;
try {
client = NetworkManager.getClient(serverUrl);
} catch (error) {
return {error};
}
try {
const data = await client.doPostActionWithCookie(postId, actionId, actionCookie, selectedOption);
if (data?.trigger_id) {
await operator.handleSystem({
systems: [{
id: SYSTEM_IDENTIFIERS.INTEGRATION_TRIGGER_ID,
value: data.trigger_id,
}],
prepareRecordsOnly: false,
});
}
return {data};
} catch (error) {
forceLogoutIfNecessary(serverUrl, error as ClientErrorProps);
return {error};
}
};
export async function fetchMissingChannelsFromPosts(serverUrl: string, posts: Post[], fetchOnly = false) {
const operator = DatabaseManager.serverDatabases[serverUrl]?.operator;
if (!operator) {
@@ -988,7 +956,3 @@ export async function fetchSavedPosts(serverUrl: string, teamId?: string, channe
return {error};
}
}
export const selectAttachmentMenuAction = (serverUrl: string, postId: string, actionId: string, selectedOption: string) => {
return postActionWithCookie(serverUrl, postId, actionId, '', selectedOption);
};

View File

@@ -32,6 +32,7 @@ import {handleChannelConvertedEvent, handleChannelCreatedEvent,
handleDirectAddedEvent,
handleUserAddedToChannelEvent,
handleUserRemovedFromChannelEvent} from './channel';
import {handleOpenDialogEvent} from './integrations';
import {handleNewPostEvent, handlePostDeleted, handlePostEdited, handlePostUnread} from './posts';
import {handlePreferenceChangedEvent, handlePreferencesChangedEvent, handlePreferencesDeletedEvent} from './preferences';
import {handleAddCustomEmoji, handleReactionRemovedFromPostEvent, handleReactionAddedToPostEvent} from './reactions';
@@ -369,6 +370,7 @@ export async function handleEvent(serverUrl: string, msg: WebSocketMessage) {
break;
case WebsocketEvents.OPEN_DIALOG:
handleOpenDialogEvent(serverUrl, msg);
break;
case WebsocketEvents.THREAD_UPDATED:

View File

@@ -0,0 +1,26 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import DatabaseManager from '@database/manager';
import IntegrationsManager from '@managers/integrations_manager';
import {getActiveServerUrl} from '@queries/app/servers';
export async function handleOpenDialogEvent(serverUrl: string, msg: WebSocketMessage) {
const data: string = msg.data?.dialog;
if (!data) {
return;
}
const appDatabase = DatabaseManager.appDatabase?.database;
if (!appDatabase) {
return;
}
try {
const dialog: InteractiveDialogConfig = JSON.parse(data);
const currentServer = await getActiveServerUrl(appDatabase);
if (currentServer === serverUrl) {
IntegrationsManager.getManager(serverUrl).setDialog(dialog);
}
} catch {
// Do nothing
}
}

View File

@@ -10,7 +10,7 @@ import {fetchAllTeams, handleTeamChange, fetchMyTeam} from '@actions/remote/team
import {updateUsersNoLongerVisible} from '@actions/remote/user';
import Events from '@constants/events';
import DatabaseManager from '@database/manager';
import {queryActiveServer} from '@queries/app/servers';
import {getActiveServerUrl} from '@queries/app/servers';
import {getCurrentTeamId} from '@queries/servers/system';
import {getLastTeam, prepareMyTeams} from '@queries/servers/team';
import {getCurrentUser} from '@queries/servers/user';
@@ -38,9 +38,13 @@ export async function handleLeaveTeamEvent(serverUrl: string, msg: WebSocketMess
}
if (currentTeamId === teamId) {
const currentServer = await queryActiveServer(DatabaseManager.appDatabase!.database);
const appDatabase = DatabaseManager.appDatabase?.database;
let currentServer = '';
if (appDatabase) {
currentServer = await getActiveServerUrl(appDatabase);
}
if (currentServer?.url === serverUrl) {
if (currentServer === serverUrl) {
DeviceEventEmitter.emit(Events.LEAVE_TEAM);
await dismissAllModals();
await popToRoot();

View File

@@ -4,18 +4,18 @@
import {buildQueryString} from '@utils/helpers';
export interface ClientAppsMix {
executeAppCall: (call: AppCallRequest, type: AppCallType) => Promise<AppCallResponse>;
executeAppCall: (call: AppCallRequest, trackAsSubmit: boolean) => Promise<AppCallResponse>;
getAppsBindings: (userID: string, channelID: string, teamID: string) => Promise<AppBinding[]>;
}
const ClientApps = (superclass: any) => class extends superclass {
executeAppCall = async (call: AppCallRequest, type: AppCallType) => {
executeAppCall = async (call: AppCallRequest, trackAsSubmit: boolean) => {
const callCopy = {
...call,
path: `${call.path}/${type}`,
context: {
...call.context,
user_agent: 'mobile',
track_as_submit: trackAsSubmit,
},
};

View File

@@ -3,16 +3,20 @@
import {withDatabase} from '@nozbe/watermelondb/DatabaseProvider';
import withObservables from '@nozbe/with-observables';
import React, {ReactNode, useCallback, useState} from 'react';
import {useIntl} from 'react-intl';
import React, {useCallback, useEffect, useState} from 'react';
import {IntlShape, useIntl} from 'react-intl';
import {Text, View} from 'react-native';
import CompasIcon from '@components/compass_icon';
import FormattedText from '@components/formatted_text';
import Footer from '@components/settings/footer';
import Label from '@components/settings/label';
import TouchableWithFeedback from '@components/touchable_with_feedback';
import {Screens, View as ViewConstants} from '@constants';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {observeTeammateNameDisplay} from '@queries/servers/user';
import DatabaseManager from '@database/manager';
import {getChannelById} from '@queries/servers/channel';
import {getUserById, observeTeammateNameDisplay} from '@queries/servers/user';
import {goToScreen} from '@screens/navigation';
import {preventDoubleTap} from '@utils/tap';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
@@ -20,21 +24,25 @@ import {displayUsername} from '@utils/user';
import type {WithDatabaseArgs} from '@typings/database/database';
type Selection = DialogOption | Channel | UserProfile | DialogOption[] | Channel[] | UserProfile[];
type AutoCompleteSelectorProps = {
dataSource?: string;
disabled?: boolean;
errorText?: ReactNode;
errorText?: string;
getDynamicOptions?: (userInput?: string) => Promise<DialogOption[]>;
helpText?: ReactNode;
helpText?: string;
label?: string;
onSelected?: (selectedItem?: PostActionOption) => Promise<void>;
onSelected?: (value: string | string[]) => void;
optional?: boolean;
options?: PostActionOption[];
placeholder: string;
placeholder?: string;
roundedBorders?: boolean;
selected?: PostActionOption;
selected?: string | string[];
showRequiredAsterisk?: boolean;
teammateNameDisplay: string;
isMultiselect?: boolean;
testID: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
@@ -75,148 +83,132 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
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,
},
};
});
const AutoCompleteSelector = ({
dataSource, disabled, errorText, getDynamicOptions, helpText, label, onSelected, optional = false,
options, placeholder, roundedBorders = true, selected, showRequiredAsterisk = false, teammateNameDisplay,
}: AutoCompleteSelectorProps) => {
async function getItemName(serverUrl: string, selected: string, teammateNameDisplay: string, intl: IntlShape, dataSource?: string, options?: PostActionOption[]) {
const database = DatabaseManager.serverDatabases[serverUrl]?.database;
switch (dataSource) {
case ViewConstants.DATA_SOURCE_USERS: {
if (!database) {
return intl.formatMessage({id: 'channel_loader.someone', defaultMessage: 'Someone'});
}
const user = await getUserById(database, selected);
return displayUsername(user, intl.locale, teammateNameDisplay, true);
}
case ViewConstants.DATA_SOURCE_CHANNELS: {
if (!database) {
return intl.formatMessage({id: 'autocomplete_selector.unknown_channel', defaultMessage: 'Unknown channel'});
}
const channel = await getChannelById(database, selected);
return channel?.displayName || intl.formatMessage({id: 'autocomplete_selector.unknown_channel', defaultMessage: 'Unknown channel'});
}
default:
return options?.find((o) => o.value === selected)?.text || selected;
}
}
function getTextAndValueFromSelectedItem(item: DialogOption | Channel | UserProfile, teammateNameDisplay: string, locale: string, dataSource?: string) {
if (dataSource === ViewConstants.DATA_SOURCE_USERS) {
const user = item as UserProfile;
return {text: displayUsername(user, locale, teammateNameDisplay), value: user.id};
} else if (dataSource === ViewConstants.DATA_SOURCE_CHANNELS) {
const channel = item as Channel;
return {text: channel.display_name, value: channel.id};
}
const option = item as DialogOption;
return option;
}
function AutoCompleteSelector({
dataSource, disabled = false, errorText, getDynamicOptions, helpText, label, onSelected, optional = false,
options, placeholder, roundedBorders = true, selected, teammateNameDisplay, isMultiselect = false, testID,
}: AutoCompleteSelectorProps) {
const intl = useIntl();
const theme = useTheme();
const [itemText, setItemText] = useState(selected?.text);
const [itemText, setItemText] = useState('');
const style = getStyleSheet(theme);
const title = placeholder || intl.formatMessage({id: 'mobile.action_menu.select', defaultMessage: 'Select an option'});
const serverUrl = useServerUrl();
const goToSelectorScreen = useCallback(preventDoubleTap(() => {
const screen = Screens.INTEGRATION_SELECTOR;
goToScreen(screen, title, {dataSource, handleSelect, options, getDynamicOptions});
goToScreen(screen, title, {dataSource, handleSelect, options, getDynamicOptions, selected, isMultiselect});
}), [dataSource, options, getDynamicOptions]);
const handleSelect = useCallback((item?: any) => {
const handleSelect = useCallback((item?: Selection) => {
if (!item) {
return;
}
let selectedText;
let selectedValue;
if (dataSource === ViewConstants.DATA_SOURCE_USERS) {
selectedText = displayUsername(item, undefined, teammateNameDisplay);
selectedValue = item.id;
} else if (dataSource === ViewConstants.DATA_SOURCE_CHANNELS) {
selectedText = item.display_name;
selectedValue = item.id;
} else {
selectedText = item.text;
selectedValue = item.value;
if (!Array.isArray(item)) {
const {text: selectedText, value: selectedValue} = getTextAndValueFromSelectedItem(item, teammateNameDisplay, intl.locale, dataSource);
setItemText(selectedText);
if (onSelected) {
onSelected(selectedValue);
}
return;
}
setItemText(selectedText);
const allSelectedTexts = [];
const allSelectedValues = [];
for (const i of item) {
const {text: selectedText, value: selectedValue} = getTextAndValueFromSelectedItem(i, teammateNameDisplay, intl.locale, dataSource);
allSelectedTexts.push(selectedText);
allSelectedValues.push(selectedValue);
}
setItemText(allSelectedTexts.join(', '));
if (onSelected) {
onSelected({text: selectedText, value: selectedValue});
onSelected(allSelectedValues);
}
}, [teammateNameDisplay, intl, dataSource]);
// Handle the text for the default value.
useEffect(() => {
if (!selected) {
return;
}
if (!Array.isArray(selected)) {
getItemName(serverUrl, selected, teammateNameDisplay, intl, dataSource, options).then((res) => setItemText(res));
return;
}
const namePromises = [];
for (const item of selected) {
namePromises.push(getItemName(serverUrl, item, teammateNameDisplay, intl, dataSource, options));
}
Promise.all(namePromises).then((names) => {
setItemText(names.join(', '));
});
}, []);
let text = title;
let selectedStyle = style.dropdownPlaceholder;
if (itemText) {
text = itemText;
selectedStyle = style.dropdownSelected;
}
let inputStyle = style.input;
if (roundedBorders) {
inputStyle = style.roundedInput;
}
let optionalContent;
let asterisk;
if (optional) {
optionalContent = (
<FormattedText
id='channel_modal.optional'
defaultMessage='(optional)'
style={style.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}
{Boolean(label) && (
<Label
label={label!}
optional={optional}
testID={testID}
/>
)}
<TouchableWithFeedback
disabled={disabled}
onPress={goToSelectorScreen}
style={disabled ? style.disabled : null}
type='opacity'
>
<View style={inputStyle}>
<View style={roundedBorders ? style.roundedInput : style.input}>
<Text
numberOfLines={1}
style={selectedStyle}
style={itemText ? style.dropdownSelected : style.dropdownPlaceholder}
>
{text}
{itemText || title}
</Text>
<CompasIcon
name='chevron-down'
@@ -225,11 +217,14 @@ const AutoCompleteSelector = ({
/>
</View>
</TouchableWithFeedback>
{helpTextContent}
{errorTextContent}
<Footer
disabled={disabled}
helpText={helpText}
errorText={errorText}
/>
</View>
);
};
}
const withTeammateNameDisplay = withObservables([], ({database}: WithDatabaseArgs) => {
return {

View File

@@ -7,11 +7,11 @@ import {useIntl} from 'react-intl';
import Button from 'react-native-button';
import {map} from 'rxjs/operators';
import {doAppCall, postEphemeralCallResponseForPost} from '@actions/remote/apps';
import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@constants/apps';
import {handleBindingClick, postEphemeralCallResponseForPost} from '@actions/remote/apps';
import {AppBindingLocations, AppCallResponseTypes} from '@constants/apps';
import {useServerUrl} from '@context/server';
import {observeCurrentTeamId} from '@queries/servers/system';
import {createCallContext, createCallRequest} from '@utils/apps';
import {createCallContext} from '@utils/apps';
import {getStatusColors} from '@utils/message_attachment_colors';
import {preventDoubleTap} from '@utils/tap';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
@@ -59,7 +59,7 @@ const ButtonBinding = ({currentTeamId, binding, post, teamID, theme}: Props) =>
const style = getStyleSheet(theme);
const onPress = useCallback(preventDoubleTap(async () => {
if (!binding.call || pressed.current) {
if (pressed.current) {
return;
}
@@ -73,18 +73,12 @@ const ButtonBinding = ({currentTeamId, binding, post, teamID, theme}: Props) =>
post.id,
);
const call = createCallRequest(
binding.call,
context,
{post: AppExpandLevels.EXPAND_ALL},
);
const res = await doAppCall(serverUrl, call, AppCallTypes.SUBMIT, intl, theme);
const res = await handleBindingClick(serverUrl, binding, context, intl);
pressed.current = false;
if (res.error) {
const errorResponse = res.error as AppCallResponse<unknown>;
const errorMessage = errorResponse.error || intl.formatMessage({
const errorResponse = res.error;
const errorMessage = errorResponse.text || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
@@ -96,8 +90,8 @@ const ButtonBinding = ({currentTeamId, binding, post, teamID, theme}: Props) =>
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
postEphemeralCallResponseForPost(serverUrl, callResp, callResp.markdown, post);
if (callResp.text) {
postEphemeralCallResponseForPost(serverUrl, callResp, callResp.text, post);
}
return;
case AppCallResponseTypes.NAVIGATE:
@@ -117,7 +111,7 @@ const ButtonBinding = ({currentTeamId, binding, post, teamID, theme}: Props) =>
return (
<Button
containerStyle={[style.button]}
containerStyle={style.button}
disabledContainerStyle={style.buttonDisabled}
onPress={onPress}
>

View File

@@ -1,10 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import React, {useEffect, useState} from 'react';
import {View} from 'react-native';
import {copyAndFillBindings} from '@utils/apps';
import {AppBindingLocations} from '@constants/apps';
import {cleanBinding} from '@utils/apps';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import EmbedText from './embed_text';
@@ -38,8 +39,13 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
const EmbeddedBinding = ({embed, post, theme}: Props) => {
const style = getStyleSheet(theme);
const [cleanedBindings, setCleanedBindings] = useState<AppBinding[]>([]);
const bindings: AppBinding[] | undefined = copyAndFillBindings(embed)?.bindings;
useEffect(() => {
const copiedBindings = JSON.parse(JSON.stringify(embed)) as AppBinding;
const bindings = cleanBinding(copiedBindings, AppBindingLocations.IN_POST)?.bindings;
setCleanedBindings(bindings!);
}, [embed]);
return (
<>
@@ -56,9 +62,9 @@ const EmbeddedBinding = ({embed, post, theme}: Props) => {
theme={theme}
/>
}
{Boolean(bindings?.length) &&
{Boolean(cleanedBindings?.length) &&
<EmbedSubBindings
bindings={bindings!}
bindings={cleanedBindings}
post={post}
theme={theme}
/>

View File

@@ -18,7 +18,7 @@ const EmbeddedSubBindings = ({bindings, post, theme}: Props) => {
const content = [] as React.ReactNode[];
bindings.forEach((binding) => {
if (!binding.app_id || !binding.call) {
if (!binding.app_id || !(binding.submit || binding.form?.submit || binding.form?.source || binding.bindings?.length)) {
return;
}
@@ -28,7 +28,6 @@ const EmbeddedSubBindings = ({bindings, post, theme}: Props) => {
key={binding.location}
binding={binding}
post={post}
theme={theme}
/>,
);
return;

View File

@@ -6,12 +6,12 @@ import React, {useCallback, useState} from 'react';
import {useIntl} from 'react-intl';
import {map} from 'rxjs/operators';
import {doAppCall, postEphemeralCallResponseForPost} from '@actions/remote/apps';
import {handleBindingClick, postEphemeralCallResponseForPost} from '@actions/remote/apps';
import AutocompleteSelector from '@components/autocomplete_selector';
import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@constants/apps';
import {AppBindingLocations, AppCallResponseTypes} from '@constants/apps';
import {useServerUrl} from '@context/server';
import {observeCurrentTeamId} from '@queries/servers/system';
import {createCallContext, createCallRequest} from '@utils/apps';
import {createCallContext} from '@utils/apps';
import type ChannelModel from '@typings/database/models/servers/channel';
import type PostModel from '@typings/database/models/servers/post';
@@ -21,30 +21,25 @@ type Props = {
currentTeamId: string;
post: PostModel;
teamID?: string;
theme: Theme;
}
const MenuBinding = ({binding, currentTeamId, post, teamID, theme}: Props) => {
const [selected, setSelected] = useState<PostActionOption>();
const MenuBinding = ({binding, currentTeamId, post, teamID}: Props) => {
const [selected, setSelected] = useState<string>();
const intl = useIntl();
const serverUrl = useServerUrl();
const onSelect = useCallback(async (picked?: PostActionOption) => {
if (!picked) {
const onSelect = useCallback(async (picked?: string | string[]) => {
if (!picked || Array.isArray(picked)) { // We are sure AutocompleteSelector only returns one, since it is not multiselect.
return;
}
setSelected(picked);
const bind = binding.bindings?.find((b) => b.location === picked.value);
const bind = binding.bindings?.find((b) => b.location === picked);
if (!bind) {
console.debug('Trying to select element not present in binding.'); //eslint-disable-line no-console
return;
}
if (!bind.call) {
return;
}
const context = createCallContext(
bind.app_id,
AppBindingLocations.IN_POST + bind.location,
@@ -53,16 +48,10 @@ const MenuBinding = ({binding, currentTeamId, post, teamID, theme}: Props) => {
post.id,
);
const call = createCallRequest(
bind.call,
context,
{post: AppExpandLevels.EXPAND_ALL},
);
const res = await doAppCall(serverUrl, call, AppCallTypes.SUBMIT, intl, theme);
const res = await handleBindingClick(serverUrl, bind, context, intl);
if (res.error) {
const errorResponse = res.error as AppCallResponse<unknown>;
const errorMessage = errorResponse.error || intl.formatMessage({
const errorMessage = errorResponse.text || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
@@ -73,8 +62,8 @@ const MenuBinding = ({binding, currentTeamId, post, teamID, theme}: Props) => {
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
postEphemeralCallResponseForPost(serverUrl, callResp, callResp.markdown, post);
if (callResp.text) {
postEphemeralCallResponseForPost(serverUrl, callResp, callResp.text, post);
}
return;
case AppCallResponseTypes.NAVIGATE:
@@ -100,6 +89,7 @@ const MenuBinding = ({binding, currentTeamId, post, teamID, theme}: Props) => {
options={options}
selected={selected}
onSelected={onSelect}
testID={`embedded_binding.${binding.location}`}
/>
);
};

View File

@@ -4,7 +4,7 @@
import React, {useCallback, useRef} from 'react';
import Button from 'react-native-button';
import {postActionWithCookie} from '@actions/remote/post';
import {postActionWithCookie} from '@actions/remote/integrations';
import {useServerUrl} from '@context/server';
import {getStatusColors} from '@utils/message_attachment_colors';
import {preventDoubleTap} from '@utils/tap';

View File

@@ -3,7 +3,7 @@
import React, {useCallback, useState} from 'react';
import {selectAttachmentMenuAction} from '@actions/remote/post';
import {selectAttachmentMenuAction} from '@actions/remote/integrations';
import AutocompleteSelector from '@components/autocomplete_selector';
import {useServerUrl} from '@context/server';
@@ -23,14 +23,14 @@ const ActionMenu = ({dataSource, defaultOption, disabled, id, name, options, pos
if (defaultOption && options) {
isSelected = options.find((option) => option.value === defaultOption);
}
const [selected, setSelected] = useState(isSelected);
const [selected, setSelected] = useState(isSelected?.value);
const handleSelect = useCallback(async (selectedItem?: PostActionOption) => {
if (!selectedItem) {
const handleSelect = useCallback(async (selectedItem: string | string[]) => {
if (Array.isArray(selectedItem)) { // Since AutocompleteSelector is not multiselect, we are sure we only receive a string
return;
}
const result = await selectAttachmentMenuAction(serverUrl, postId, id, selectedItem.value);
const result = await selectAttachmentMenuAction(serverUrl, postId, id, selectedItem);
if (result.data?.trigger_id) {
setSelected(selectedItem);
}
@@ -44,6 +44,7 @@ const ActionMenu = ({dataSource, defaultOption, disabled, id, name, options, pos
selected={selected}
onSelected={handleSelect}
disabled={disabled}
testID={`message_attachment.${name}`}
/>
);
};

View File

@@ -0,0 +1,107 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {View, Text, Switch} from 'react-native';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import Footer from './footer';
import Label from './label';
type Props = {
label?: string;
value: boolean;
placeholder?: string;
helpText?: string;
errorText?: string;
disabledText?: string;
optional?: boolean;
disabled?: boolean;
onChange: (value: boolean) => void;
testID: string;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
inputContainer: {
backgroundColor: theme.centerChannelBg,
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 15,
height: 40,
},
disabled: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
},
placeholderText: {
color: changeOpacity(theme.centerChannelColor, 0.5),
fontSize: 15,
},
inputSwitch: {
position: 'absolute',
right: 12,
},
separator: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
height: 1,
width: '100%',
},
};
});
function BoolSetting({
label,
value,
placeholder,
helpText,
errorText,
disabledText,
optional = false,
disabled = false,
onChange,
testID,
}: Props) {
const theme = useTheme();
const style = getStyleSheet(theme);
const inputContainerStyle = useMemo(() => (disabled ? [style.inputContainer, style.disabled] : style.inputContainer), [style, disabled]);
return (
<>
{label && (
<>
<Label
label={label}
optional={optional}
testID={testID}
/>
<View style={style.separator}/>
</>
)}
<View style={[inputContainerStyle]}>
<Text style={style.placeholderText}>
{placeholder}
</Text>
<Switch
onValueChange={onChange}
value={value}
style={style.inputSwitch}
disabled={disabled}
/>
</View>
<View style={style.separator}/>
<View>
<Footer
disabled={disabled}
disabledText={disabledText}
errorText={errorText}
helpText={helpText}
/>
</View>
</>
);
}
export default BoolSetting;

View File

@@ -0,0 +1,89 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import Markdown from '@components/markdown';
import {useTheme} from '@context/theme';
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
helpTextContainer: {
marginHorizontal: 15,
marginTop: 10,
},
helpText: {
fontSize: 12,
color: changeOpacity(theme.centerChannelColor, 0.5),
},
errorTextContainer: {
marginHorizontal: 15,
marginVertical: 10,
},
errorText: {
fontSize: 12,
color: theme.errorTextColor,
},
};
});
type Props = {
disabled: boolean;
disabledText?: string;
helpText?: string;
errorText?: string;
}
function Footer({
disabled,
disabledText,
helpText,
errorText,
}: Props) {
const theme = useTheme();
const style = getStyleSheet(theme);
const textStyles = getMarkdownTextStyles(theme);
const blockStyles = getMarkdownBlockStyles(theme);
return (
<>
{disabled && Boolean(disabledText) && (
<View style={style.helpTextContainer} >
<Markdown
baseTextStyle={style.helpText}
textStyles={textStyles}
blockStyles={blockStyles}
value={disabledText}
theme={theme}
/>
</View>
)}
{Boolean(helpText) && (
<View style={style.helpTextContainer} >
<Markdown
baseTextStyle={style.helpText}
textStyles={textStyles}
blockStyles={blockStyles}
value={helpText}
theme={theme}
/>
</View>
)}
{Boolean(errorText) && (
<View style={style.errorTextContainer} >
<Markdown
baseTextStyle={style.errorText}
textStyles={textStyles}
blockStyles={blockStyles}
value={errorText}
theme={theme}
/>
</View>
)}
</>
);
}
export default Footer;

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {Text, View} from 'react-native';
import FormattedText from '@components/formatted_text';
import {useTheme} from '@context/theme';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
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,
},
asterisk: {
color: theme.errorTextColor,
fontSize: 14,
},
};
});
type Props = {
label: string;
optional: boolean;
testID: string;
}
function Label({
label,
optional,
testID,
}: Props) {
const theme = useTheme();
const style = getStyleSheet(theme);
return (
<View style={style.labelContainer}>
<Text
style={style.label}
testID={`${testID}.label`}
>
{label}
</Text>
{!optional && (<Text style={style.asterisk}>{' *'}</Text>)}
{optional && (
<FormattedText
style={style.optional}
id='channel_modal.optional'
defaultMessage='(optional)'
/>
)}
</View>
);
}
export default Label;

View File

@@ -0,0 +1,88 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {View} from 'react-native';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
import Footer from '../footer';
import Label from '../label';
import RadioEntry from './radio_entry';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
items: {
backgroundColor: theme.centerChannelBg,
borderTopWidth: 1,
borderBottomWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.1),
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1),
},
};
});
type Props = {
label: string;
options?: PostActionOption[];
onChange: (value: string) => void;
helpText?: string;
errorText?: string;
value: string;
testID: string;
}
function RadioSetting({
label,
options,
onChange,
helpText = '',
errorText = '',
testID,
value,
}: Props) {
const theme = useTheme();
const style = getStyleSheet(theme);
const optionsRender = useMemo(() => {
if (!options) {
return [];
}
const elements = [];
for (const [i, {value: entryValue, text}] of options.entries()) {
elements.push(
<RadioEntry
handleChange={onChange}
isLast={i === options.length - 1}
isSelected={value === entryValue}
text={text}
value={entryValue}
key={value}
/>,
);
}
return elements;
}, [value, onChange, options]);
return (
<View>
<Label
label={label}
optional={false}
testID={testID}
/>
<View style={style.items}>
{optionsRender}
</View>
<Footer
disabled={false}
errorText={errorText}
helpText={helpText}
/>
</View>
);
}
export default RadioSetting;

View File

@@ -0,0 +1,80 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {Text, TouchableOpacity, View} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {useTheme} from '@context/theme';
import {makeStyleSheetFromTheme, changeOpacity} from '@utils/theme';
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 15,
},
rowContainer: {
alignItems: 'center',
flex: 1,
flexDirection: 'row',
height: 45,
},
separator: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
flex: 1,
height: 1,
marginLeft: 15,
},
checkMark: {
fontSize: 12,
color: theme.linkColor,
},
};
});
type Props = {
handleChange: (value: string) => void;
value: string;
text: string;
isLast: boolean;
isSelected: boolean;
}
function RadioEntry({
handleChange,
value,
text,
isLast,
isSelected,
}: Props) {
const theme = useTheme();
const style = getStyleSheet(theme);
const onPress = useCallback(() => {
handleChange(value);
}, [handleChange, value]);
return (
<TouchableOpacity
onPress={onPress}
key={value}
>
<View style={style.container}>
<View style={style.rowContainer}>
<Text>{text}</Text>
</View>
{isSelected && (
<CompassIcon
name='check'
style={style.checkmark}
/>
)}
</View>
{!isLast && (
<View style={style.separator}/>
)}
</TouchableOpacity>
);
}
export default RadioEntry;

View File

@@ -0,0 +1,132 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useMemo} from 'react';
import {View, TextInput, Platform, KeyboardTypeOptions} from 'react-native';
import {useTheme} from '@context/theme';
import {
changeOpacity,
makeStyleSheetFromTheme,
getKeyboardAppearanceFromTheme,
} from '@utils/theme';
import Footer from './footer';
import Label from './label';
const getStyleSheet = makeStyleSheetFromTheme((theme) => {
const input = {
color: theme.centerChannelColor,
fontSize: 14,
paddingHorizontal: 15,
};
return {
inputContainer: {
borderTopWidth: 1,
borderBottomWidth: 1,
borderTopColor: changeOpacity(theme.centerChannelColor, 0.1),
borderBottomColor: changeOpacity(theme.centerChannelColor, 0.1),
backgroundColor: theme.centerChannelBg,
},
input: {
...input,
height: 40,
},
multiline: {
...input,
paddingTop: 10,
paddingBottom: 13,
height: 125,
},
disabled: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.1),
},
};
});
type Props = {
label: string;
placeholder?: string;
helpText?: string;
errorText?: string;
disabled: boolean;
disabledText?: string;
maxLength?: number;
optional: boolean;
onChange: (value: string) => void;
value: string;
multiline: boolean;
keyboardType: KeyboardTypeOptions;
secureTextEntry: boolean;
testID: string;
}
function TextSetting({
label,
placeholder,
helpText,
errorText,
disabled,
disabledText,
maxLength,
optional,
onChange,
value,
multiline,
keyboardType,
secureTextEntry,
testID,
}: Props) {
const theme = useTheme();
const style = getStyleSheet(theme);
const inputContainerStyle = useMemo(() => (disabled ? [style.inputContainer, style.disabled] : style.inputContainer), [style, disabled]);
const inputStyle = useMemo(() => (multiline ? style.multiline : style.input), [multiline]);
const actualKeyboardType: KeyboardTypeOptions = keyboardType === 'url' ? Platform.select({android: 'default', default: 'url'}) : keyboardType;
return (
<View testID={testID}>
{label && (
<Label
label={label}
optional={optional}
testID={testID}
/>
)}
<View style={inputContainerStyle}>
<View>
<TextInput
allowFontScaling={true}
value={value}
placeholder={placeholder}
placeholderTextColor={changeOpacity(theme.centerChannelColor, 0.5)}
onChangeText={onChange}
style={inputStyle}
autoCapitalize='none'
autoCorrect={false}
maxLength={maxLength}
editable={!disabled}
underlineColorAndroid='transparent'
disableFullscreenUI={true}
multiline={multiline}
keyboardType={actualKeyboardType}
secureTextEntry={secureTextEntry}
keyboardAppearance={getKeyboardAppearanceFromTheme(theme)}
testID={`${testID}.input`}
/>
</View>
</View>
<View>
<Footer
disabled={disabled}
disabledText={disabledText}
errorText={errorText}
helpText={helpText}
/>
</View>
</View>
);
}
export default TextSetting;

View File

@@ -20,13 +20,6 @@ export const AppCallResponseTypes: { [name: string]: AppCallResponseType } = {
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',
@@ -52,7 +45,6 @@ export default {
AppBindingLocations,
AppBindingPresentations,
AppCallResponseTypes,
AppCallTypes,
AppExpandLevels,
AppFieldTypes,
COMMAND_SUGGESTION_ERROR,

View File

@@ -53,7 +53,6 @@ export const SYSTEM_IDENTIFIERS = {
CURRENT_USER_ID: 'currentUserId',
DATA_RETENTION_POLICIES: 'dataRetentionPolicies',
EXPANDED_LINKS: 'expandedLinks',
INTEGRATION_TRIGGER_ID: 'IntegreationTriggerId',
LICENSE: 'license',
RECENT_CUSTOM_STATUS: 'recentCustomStatus',
RECENT_MENTIONS: 'recentMentions',

View File

@@ -15,6 +15,7 @@ import Emoji from './emoji';
import Events from './events';
import Files from './files';
import General from './general';
import Integrations from './integrations';
import List from './list';
import Navigation from './navigation';
import Network from './network';
@@ -45,6 +46,7 @@ export {
Events,
Files,
General,
Integrations,
List,
Navigation,
Network,

View File

@@ -0,0 +1,8 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export const VALID_TYPES = new Set(['input', 'textarea', 'number', 'email', 'tel', 'url', 'password']);
export default {
VALID_TYPES,
};

View File

@@ -25,6 +25,7 @@ export const GALLERY = 'Gallery';
export const GLOBAL_THREADS = 'GlobalThreads';
export const HOME = 'Home';
export const INTEGRATION_SELECTOR = 'IntegrationSelector';
export const INTERACTIVE_DIALOG = 'InteractiveDialog';
export const IN_APP_NOTIFICATION = 'InAppNotification';
export const LOGIN = 'Login';
export const MENTIONS = 'Mentions';
@@ -68,6 +69,7 @@ export default {
GLOBAL_THREADS,
HOME,
INTEGRATION_SELECTOR,
INTERACTIVE_DIALOG,
IN_APP_NOTIFICATION,
LOGIN,
MENTIONS,

View File

@@ -32,6 +32,7 @@ export default {
PROFILE_PICTURE_EMOJI_SIZE,
DATA_SOURCE_USERS: 'users',
DATA_SOURCE_CHANNELS: 'channels',
DATA_SOURCE_DYNAMIC: 'dynamic',
SEARCH_INPUT_HEIGHT,
TABLET_SIDEBAR_WIDTH,
TEAM_SIDEBAR_WIDTH,

View File

@@ -2,6 +2,8 @@
// See LICENSE.txt for license information.
import {fetchCommands} from '@actions/remote/command';
import {INTERACTIVE_DIALOG} from '@constants/screens';
import {showModal} from '@screens/navigation';
const TIME_TO_REFETCH_COMMANDS = 60000; // 1 minute
class ServerIntegrationsManager {
@@ -10,6 +12,7 @@ class ServerIntegrationsManager {
private commands: {[teamId: string]: Command[] | undefined} = {};
private triggerId = '';
private storedDialog?: InteractiveDialogConfig;
private bindings: AppBinding[] = [];
private rhsBindings: AppBinding[] = [];
@@ -64,11 +67,26 @@ class ServerIntegrationsManager {
this.commandForms[key] = form;
}
public getTriggerId() {
return this.triggerId;
}
public setTriggerId(id: string) {
this.triggerId = id;
if (this.storedDialog?.trigger_id === id) {
this.showDialog();
}
}
public setDialog(dialog: InteractiveDialogConfig) {
this.storedDialog = dialog;
if (this.triggerId === dialog.trigger_id) {
this.showDialog();
}
}
private showDialog() {
const config = this.storedDialog;
if (!config) {
return;
}
showModal(INTERACTIVE_DIALOG, config.dialog.title, {config});
}
}

View File

@@ -28,6 +28,11 @@ export const queryActiveServer = async (appDatabase: Database) => {
}
};
export const getActiveServerUrl = async (appDatabase: Database) => {
const server = await queryActiveServer(appDatabase);
return server?.url || '';
};
export const queryServerByIdentifier = async (appDatabase: Database, identifier: string) => {
try {
const servers = (await appDatabase.get<ServerModel>(SERVERS).query(Q.where('identifier', identifier)).fetch());

View File

@@ -0,0 +1,443 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, ScrollView, Text, View} from 'react-native';
import Button from 'react-native-button';
import {ImageResource, Navigation} from 'react-native-navigation';
import {SafeAreaView} from 'react-native-safe-area-context';
import {handleGotoLocation} from '@actions/remote/command';
import CompassIcon from '@components/compass_icon';
import Markdown from '@components/markdown';
import {AppCallResponseTypes} from '@constants/apps';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import useDidUpdate from '@hooks/did_update';
import {filterEmptyOptions} from '@utils/apps';
import {buttonBackgroundStyle, buttonTextStyle} from '@utils/buttonStyles';
import {checkDialogElementForError, checkIfErrorsMatchElements} from '@utils/integrations';
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import DialogIntroductionText from '../interactive_dialog/dialog_introduction_text';
import {buildNavigationButton, dismissModal, setButtons} from '../navigation';
import AppsFormField from './apps_form_field';
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
return {
container: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.03),
height: '100%',
},
errorContainer: {
marginTop: 15,
marginLeft: 15,
fontSize: 14,
fontWeight: 'bold',
},
scrollView: {
marginBottom: 20,
marginTop: 10,
},
errorLabel: {
fontSize: 12,
textAlign: 'left',
color: (theme.errorTextColor || '#DA4A4A'),
},
button: buttonBackgroundStyle(theme, 'lg', 'primary', 'default'),
buttonText: buttonTextStyle(theme, 'lg', 'primary', 'default'),
};
});
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 close = () => {
Keyboard.dismiss();
dismissModal();
};
const makeCloseButton = (icon: ImageResource) => {
return buildNavigationButton(CLOSE_BUTTON_ID, 'close.more_direct_messages.button', icon);
};
export type Props = {
form: AppForm;
componentId: string;
refreshOnSelect: (field: AppField, values: AppFormValues, value: AppFormValue) => Promise<DoAppCallResult<FormResponseData>>;
submit: (submission: {values: AppFormValues}) => Promise<DoAppCallResult<FormResponseData>>;
performLookupCall: (field: AppField, values: AppFormValues, value: AppFormValue) => Promise<DoAppCallResult<AppLookupResponse>>;
}
type Errors = {[name: string]: string}
const emptyErrorsState: Errors = {};
type ValuesAction = {name: string; value: AppFormValue} | {elements?: AppField[]}
function valuesReducer(state: AppFormValues, action: ValuesAction) {
if (!('name' in action)) {
return initValues(action.elements);
}
if (state[action.name] === action.value) {
return state;
}
return {...state, [action.name]: action.value};
}
function initValues(elements?: AppField[]) {
const values: AppFormValues = {};
elements?.forEach((e) => {
if (e.type === 'bool') {
values[e.name] = (e.value === true || String(e.value).toLowerCase() === 'true');
} else if (e.value) {
values[e.name] = e.value;
}
});
return values;
}
const CLOSE_BUTTON_ID = 'close-app-form';
const SUBMIT_BUTTON_ID = 'submit-app-form';
function AppsFormComponent({
form,
componentId,
refreshOnSelect,
submit,
performLookupCall,
}: Props) {
const scrollView = useRef<ScrollView>();
const [submitting, setSubmitting] = useState(false);
const intl = useIntl();
const serverUrl = useServerUrl();
const [error, setError] = useState('');
const [errors, setErrors] = useState(emptyErrorsState);
const [values, dispatchValues] = useReducer(valuesReducer, form.fields, initValues);
const theme = useTheme();
const style = getStyleFromTheme(theme);
useEffect(() => {
const unsubscribe = Navigation.events().registerComponentListener({
navigationButtonPressed: ({buttonId}: { buttonId: string }) => {
switch (buttonId) {
case CLOSE_BUTTON_ID:
close();
break;
case SUBMIT_BUTTON_ID: {
if (!submitting) {
handleSubmit();
}
break;
}
}
},
}, componentId);
return () => {
unsubscribe.remove();
};
}, [serverUrl, componentId, submitting]);
useDidUpdate(() => {
dispatchValues({elements: form.fields});
}, [form]);
const submitButtons = useMemo(() => {
return form.fields && form.fields.find((f) => f.name === form.submit_buttons);
}, [form]);
const rightButton = useMemo(() => {
if (submitButtons) {
return undefined;
}
const base = buildNavigationButton(
SUBMIT_BUTTON_ID,
'interactive_dialog.submit.button',
undefined,
intl.formatMessage({id: 'interactive_dialog.submit', defaultMessage: 'Submit'}),
);
base.enabled = submitting;
base.showAsAction = 'always';
base.color = theme.sidebarHeaderTextColor;
return base;
}, [theme.sidebarHeaderTextColor, intl, Boolean(submitButtons)]);
useEffect(() => {
setButtons(componentId, {
rightButtons: rightButton ? [rightButton] : [],
});
}, [rightButton, componentId]);
useEffect(() => {
const icon = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
setButtons(componentId, {
leftButtons: [makeCloseButton(icon)],
});
}, [theme]);
const onChange = useCallback((name: string, value: any) => {
const field = form.fields?.find((f) => f.name === name);
if (!field) {
return;
}
const newValues = {...values, [name]: value};
if (field.refresh) {
refreshOnSelect(field, newValues, value).then((res) => {
if (res.error) {
const errorResponse = res.error;
const errorMsg = errorResponse.text;
const newErrors = errorResponse.data?.errors;
const elements = fieldsAsElements(form.fields);
updateErrors(elements, newErrors, errorMsg);
return;
}
const callResponse = res.data!;
switch (callResponse.type) {
case AppCallResponseTypes.FORM:
return;
case AppCallResponseTypes.OK:
case AppCallResponseTypes.NAVIGATE:
updateErrors([], undefined, intl.formatMessage({
id: 'apps.error.responses.unexpected_type',
defaultMessage: 'App response type was not expected. Response type: {type}.',
}, {
type: callResponse.type,
}));
return;
default:
updateErrors([], undefined, intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResponse.type,
}));
}
});
}
dispatchValues({name, value});
}, []);
const updateErrors = (elements: DialogElement[], fieldErrors?: {[x: string]: string}, formError?: string): boolean => {
let hasErrors = false;
let hasHeaderError = false;
if (formError) {
hasErrors = true;
hasHeaderError = true;
setError(formError);
}
if (fieldErrors && Object.keys(fieldErrors).length > 0) {
hasErrors = true;
if (checkIfErrorsMatchElements(fieldErrors as any, elements)) {
setErrors(fieldErrors);
} else if (!hasHeaderError) {
hasHeaderError = true;
const field = Object.keys(fieldErrors)[0];
setError(intl.formatMessage({
id: 'apps.error.responses.unknown_field_error',
defaultMessage: 'Received an error for an unknown field. Field name: `{field}`. Error: `{error}`.',
}, {
field,
error: fieldErrors[field],
}));
}
}
if (hasErrors) {
if (hasHeaderError && scrollView.current) {
scrollView.current.scrollTo({x: 0, y: 0});
}
}
return hasErrors;
};
const handleSubmit = useCallback(async (button?: string) => {
const {fields} = form;
const fieldErrors: {[name: string]: string} = {};
const elements = fieldsAsElements(fields);
let hasErrors = false;
elements?.forEach((element) => {
const newError = checkDialogElementForError(
element,
element.name === form.submit_buttons ? button : values[element.name],
);
if (newError) {
hasErrors = true;
fieldErrors[element.name] = intl.formatMessage({id: newError.id, defaultMessage: newError.defaultMessage}, newError.values);
}
});
setErrors(hasErrors ? fieldErrors : emptyErrorsState);
if (hasErrors) {
return;
}
const submission = {
values,
};
if (button && form.submit_buttons) {
submission.values[form.submit_buttons] = button;
}
setSubmitting(true);
const res = await submit(submission);
if (res.error) {
const errorResponse = res.error;
const errorMessage = errorResponse.text;
hasErrors = updateErrors(elements, errorResponse.data?.errors, errorMessage);
if (!hasErrors) {
close();
return;
}
setSubmitting(false);
return;
}
const callResponse = res.data!;
switch (callResponse.type) {
case AppCallResponseTypes.OK:
await close();
return;
case AppCallResponseTypes.NAVIGATE:
await close();
handleGotoLocation(serverUrl, intl, callResponse.navigate_to_url!);
return;
case AppCallResponseTypes.FORM:
setSubmitting(false);
return;
default:
updateErrors([], undefined, intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResponse.type,
}));
setSubmitting(false);
}
}, []);
const performLookup = useCallback(async (name: string, userInput: string): Promise<AppSelectOption[]> => {
const field = form.fields?.find((f) => f.name === name);
if (!field) {
return [];
}
const res = await performLookupCall(field, values, userInput);
if (res.error) {
const errorResponse = res.error;
const errMsg = errorResponse.text || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error.',
});
setErrors({[field.name]: errMsg});
return [];
}
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.OK: {
let items = callResp.data?.items || [];
items = items.filter(filterEmptyOptions);
return 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,
},
);
setErrors({[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,
},
);
setErrors({[field.name]: errMsg});
return [];
}
}
}, []);
return (
<SafeAreaView
testID='interactive_dialog.screen'
style={style.container}
>
<ScrollView
// @ts-expect-error legacy ref
ref={scrollView}
style={style.scrollView}
>
{error && (
<View style={style.errorContainer} >
<Markdown
baseTextStyle={style.errorLabel}
textStyles={getMarkdownTextStyles(theme)}
blockStyles={getMarkdownBlockStyles(theme)}
value={error}
theme={theme}
/>
</View>
)}
{form.header &&
<DialogIntroductionText
value={form.header}
/>
}
{form.fields && form.fields.filter((f) => f.name !== form.submit_buttons).map((field) => {
return (
<AppsFormField
field={field}
key={field.name}
name={field.name}
errorText={errors[field.name]}
value={values[field.name]}
performLookup={performLookup}
onChange={onChange}
/>
);
})}
<View
style={{marginHorizontal: 5}}
>
{submitButtons?.options?.map((o) => (
<Button
key={o.value}
onPress={() => handleSubmit(o.value)}
containerStyle={style.button}
>
<Text style={style.buttonText}>{o.label}</Text>
</Button>
))}
</View>
</ScrollView>
</SafeAreaView>
);
}
export default AppsFormComponent;

View File

@@ -0,0 +1,173 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {View} from 'react-native';
import AutocompleteSelector from '@components/autocomplete_selector';
import Markdown from '@components/markdown';
import BoolSetting from '@components/settings/bool_setting';
import TextSetting from '@components/settings/text_setting';
import {View as ViewConstants} from '@constants';
import {AppFieldTypes} from '@constants/apps';
import {useTheme} from '@context/theme';
import {selectKeyboardType} from '@utils/integrations';
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
import {makeStyleSheetFromTheme} from '@utils/theme';
const TEXT_DEFAULT_MAX_LENGTH = 150;
const TEXTAREA_DEFAULT_MAX_LENGTH = 3000;
export type Props = {
field: AppField;
name: string;
errorText?: string;
value: AppFormValue;
onChange: (name: string, value: string | string[] | boolean) => void;
performLookup: (name: string, userInput: string) => Promise<AppSelectOption[]>;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
return {
markdownFieldContainer: {
marginTop: 15,
marginBottom: 10,
marginLeft: 15,
},
markdownFieldText: {
fontSize: 14,
color: theme.centerChannelColor,
},
};
});
function selectDataSource(fieldType: string): string {
switch (fieldType) {
case AppFieldTypes.USER:
return ViewConstants.DATA_SOURCE_USERS;
case AppFieldTypes.CHANNEL:
return ViewConstants.DATA_SOURCE_CHANNELS;
case AppFieldTypes.DYNAMIC_SELECT:
return ViewConstants.DATA_SOURCE_DYNAMIC;
default:
return '';
}
}
function AppsFormField({
field,
name,
errorText,
value,
onChange,
performLookup,
}: Props) {
const theme = useTheme();
const style = getStyleSheet(theme);
const testID = `AppFormElement.${name}`;
const placeholder = field.hint || '';
const displayName = field.modal_label || field.label || '';
const handleChange = useCallback((newValue: string | boolean | string[]) => {
onChange(name, newValue);
}, [name]);
const getDynamicOptions = useCallback(async (userInput = ''): Promise<DialogOption[]> => {
const options = await performLookup(field.name, userInput);
return options.map((option) => ({
text: option.label,
value: option.value,
}));
}, [performLookup, field]);
switch (field.type) {
case AppFieldTypes.TEXT: {
return (
<TextSetting
label={displayName}
maxLength={field.max_length || (field.subtype === 'textarea' ? TEXTAREA_DEFAULT_MAX_LENGTH : TEXT_DEFAULT_MAX_LENGTH)}
value={value as string}
placeholder={placeholder}
helpText={field.description}
errorText={errorText}
onChange={handleChange}
optional={!field.is_required}
multiline={field.subtype === 'textarea'}
keyboardType={selectKeyboardType(field.subtype)}
secureTextEntry={field.subtype === 'password'}
disabled={Boolean(field.readonly)}
testID={testID}
/>
);
}
case AppFieldTypes.USER:
case AppFieldTypes.CHANNEL:
case AppFieldTypes.STATIC_SELECT:
case AppFieldTypes.DYNAMIC_SELECT: {
let options: DialogOption[] | undefined;
if (field.type === AppFieldTypes.STATIC_SELECT && field.options) {
options = field.options.map((option) => ({text: option.label, value: option.value}));
}
return (
<AutocompleteSelector
label={displayName}
dataSource={selectDataSource(field.type)}
options={options}
optional={!field.is_required}
onSelected={handleChange}
getDynamicOptions={getDynamicOptions}
helpText={field.description}
errorText={errorText}
placeholder={placeholder}
showRequiredAsterisk={true}
selected={value as string | string[]}
roundedBorders={false}
disabled={field.readonly}
isMultiselect={field.multiselect}
testID={testID}
/>
);
}
case AppFieldTypes.BOOL: {
return (
<BoolSetting
label={displayName}
value={value as boolean}
placeholder={placeholder}
helpText={field.description}
errorText={errorText}
optional={!field.is_required}
onChange={handleChange}
disabled={field.readonly}
testID={testID}
/>
);
}
case AppFieldTypes.MARKDOWN: {
if (!field.description) {
return null;
}
return (
<View
style={style.markdownFieldContainer}
>
<Markdown
value={field.description}
mentionKeys={[]}
blockStyles={getMarkdownBlockStyles(theme)}
textStyles={getMarkdownTextStyles(theme)}
baseTextStyle={style.markdownFieldText}
theme={theme}
/>
</View>
);
}
}
return null;
}
export default AppsFormField;

View File

@@ -0,0 +1,203 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useState} from 'react';
import {useIntl} from 'react-intl';
import {doAppFetchForm, doAppLookup, doAppSubmit, postEphemeralCallResponseForContext} from '@actions/remote/apps';
import {AppCallResponseTypes} from '@constants/apps';
import {useServerUrl} from '@context/server';
import {createCallRequest, makeCallErrorResponse} from '@utils/apps';
import AppsFormComponent from './apps_form_component';
export type Props = {
form?: AppForm;
context?: AppContext;
componentId: string;
};
function AppsFormContainer({
form,
context,
componentId,
}: Props) {
const intl = useIntl();
const [currentForm, setCurrentForm] = useState(form);
const serverUrl = useServerUrl();
const submit = useCallback(async (submission: {values: AppFormValues}): Promise<{data?: AppCallResponse<FormResponseData>; error?: AppCallResponse<FormResponseData>}> => {
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},
);
};
if (!currentForm) {
return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage(
{
id: 'apps.error.form.no_form',
defaultMessage: '`form` is not defined',
},
)))};
}
if (!currentForm.submit) {
return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage(
{
id: 'apps.error.form.no_submit',
defaultMessage: '`submit` is not defined',
},
)))};
}
if (!context) {
return {error: makeCallErrorResponse('unreachable: empty context')};
}
const creq = createCallRequest(currentForm.submit, context, {}, submission.values);
const res = await doAppSubmit(serverUrl, creq, intl) as DoAppCallResult<FormResponseData>;
if (res.error) {
return res;
}
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.text) {
postEphemeralCallResponseForContext(serverUrl, callResp, callResp.text, creq.context);
}
break;
case AppCallResponseTypes.FORM:
setCurrentForm(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;
}, []);
const refreshOnSelect = useCallback(async (field: AppField, values: AppFormValues): Promise<DoAppCallResult<FormResponseData>> => {
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},
);
if (!currentForm) {
return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage({
id: 'apps.error.form.no_form',
defaultMessage: '`form` is not defined.',
})))};
}
if (!currentForm.source) {
return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage({
id: 'apps.error.form.no_source',
defaultMessage: '`source` 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.',
})))};
}
if (!context) {
return {error: makeCallErrorResponse('unreachable: empty context')};
}
const creq = createCallRequest(currentForm.source, context, {}, values);
creq.selected_field = field.name;
const res = await doAppFetchForm<FormResponseData>(serverUrl, creq, intl);
if (res.error) {
return res;
}
const callResp = res.data!;
switch (callResp.type) {
case AppCallResponseTypes.FORM:
setCurrentForm(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;
}, []);
const performLookupCall = useCallback(async (field: AppField, values: AppFormValues, userInput: string): Promise<DoAppCallResult<AppLookupResponse>> => {
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},
);
if (!field.lookup) {
return {error: makeCallErrorResponse(makeErrorMsg(intl.formatMessage({
id: 'apps.error.form.no_lookup',
defaultMessage: '`lookup` is not defined.',
})))};
}
if (!context) {
return {error: makeCallErrorResponse('unreachable: empty context')};
}
const creq = createCallRequest(field.lookup, context, {}, values);
creq.selected_field = field.name;
creq.query = userInput;
return doAppLookup<AppLookupResponse>(serverUrl, creq, intl);
}, []);
if (!currentForm?.submit || !context) {
return null;
}
return (
<AppsFormComponent
form={currentForm}
componentId={componentId}
performLookupCall={performLookupCall}
refreshOnSelect={refreshOnSelect}
submit={submit}
/>
);
}
export default AppsFormContainer;

View File

@@ -57,6 +57,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.ABOUT:
screen = withServerDatabase(require('@screens/about').default);
break;
case Screens.APP_FORM:
screen = withServerDatabase(require('@screens/apps_form').default);
break;
case Screens.BOTTOM_SHEET:
screen = withServerDatabase(
require('@screens/bottom_sheet').default,
@@ -115,6 +118,9 @@ Navigation.setLazyComponentRegistrator((screenName) => {
case Screens.GLOBAL_THREADS:
screen = withServerDatabase(require('@screens/global_threads').default);
break;
case Screens.INTERACTIVE_DIALOG:
screen = withServerDatabase(require('@screens/interactive_dialog').default);
break;
case Screens.IN_APP_NOTIFICATION: {
const notificationScreen =
require('@screens/in_app_notification').default;

View File

@@ -0,0 +1,130 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {KeyboardTypeOptions} from 'react-native';
import AutocompleteSelector from '@components/autocomplete_selector';
import BoolSetting from '@components/settings/bool_setting';
import RadioSetting from '@components/settings/radio_setting';
import TextSetting from '@components/settings/text_setting';
import {selectKeyboardType as selectKB} from '@utils/integrations';
const TEXT_DEFAULT_MAX_LENGTH = 150;
const TEXTAREA_DEFAULT_MAX_LENGTH = 3000;
function selectKeyboardType(type: InteractiveDialogElementType, subtype?: InteractiveDialogTextSubtype): KeyboardTypeOptions {
if (type === 'textarea') {
return 'default';
}
return selectKB(subtype);
}
type Props = {
displayName: string;
name: string;
type: InteractiveDialogElementType;
subtype?: InteractiveDialogTextSubtype;
placeholder?: string;
helpText?: string;
errorText?: string;
maxLength?: number;
dataSource?: string;
optional?: boolean;
options?: PostActionOption[];
value: string|number|boolean|string[];
onChange: (name: string, value: string|number|boolean|string[]) => void;
}
function DialogElement({
displayName,
name,
type,
subtype,
placeholder,
helpText,
errorText,
maxLength,
dataSource,
optional = false,
options,
value,
onChange,
}: Props) {
const testID = `InteractiveDialogElement.${name}`;
const handleChange = useCallback((newValue: string | boolean | string[]) => {
if (type === 'text' && subtype === 'number') {
onChange(name, parseInt(newValue as string, 10));
return;
}
onChange(name, newValue);
}, [onChange, type, subtype]);
switch (type) {
case 'text':
case 'textarea':
return (
<TextSetting
label={displayName}
maxLength={maxLength || (type === 'text' ? TEXT_DEFAULT_MAX_LENGTH : TEXTAREA_DEFAULT_MAX_LENGTH)}
value={value as string}
placeholder={placeholder}
helpText={helpText}
errorText={errorText}
onChange={handleChange}
optional={optional}
multiline={type === 'textarea'}
keyboardType={selectKeyboardType(type, subtype)}
secureTextEntry={subtype === 'password'}
disabled={false}
testID={testID}
/>
);
case 'select':
return (
<AutocompleteSelector
label={displayName}
dataSource={dataSource}
options={options}
optional={optional}
onSelected={handleChange}
helpText={helpText}
errorText={errorText}
placeholder={placeholder}
showRequiredAsterisk={true}
selected={value as string | string[]}
roundedBorders={false}
testID={testID}
/>
);
case 'radio':
return (
<RadioSetting
label={displayName}
helpText={helpText}
errorText={errorText}
options={options}
onChange={handleChange}
testID={testID}
value={value as string}
/>
);
case 'bool':
return (
<BoolSetting
label={displayName}
value={value as boolean}
placeholder={placeholder}
helpText={helpText}
errorText={errorText}
optional={optional}
onChange={handleChange}
testID={testID}
/>
);
default:
return null;
}
}
export default DialogElement;

View File

@@ -0,0 +1,50 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {View} from 'react-native';
import Markdown from '@components/markdown';
import {useTheme} from '@context/theme';
import {getMarkdownTextStyles, getMarkdownBlockStyles} from '@utils/markdown';
import {makeStyleSheetFromTheme} from '@utils/theme';
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
introductionTextView: {
marginHorizontal: 15,
},
introductionText: {
color: theme.centerChannelColor,
},
};
});
type Props = {
value: string;
}
function DialogIntroductionText({value}: Props) {
const theme = useTheme();
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}
theme={theme}
/>
</View>
);
}
export default DialogIntroductionText;

View File

@@ -0,0 +1,268 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useReducer, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {Keyboard, ScrollView} from 'react-native';
import {ImageResource, Navigation} from 'react-native-navigation';
import {SafeAreaView} from 'react-native-safe-area-context';
import {submitInteractiveDialog} from '@actions/remote/integrations';
import CompassIcon from '@components/compass_icon';
import ErrorText from '@components/error_text';
import {useServerUrl} from '@context/server';
import {useTheme} from '@context/theme';
import {buildNavigationButton, dismissModal, setButtons} from '@screens/navigation';
import {checkDialogElementForError, checkIfErrorsMatchElements} from '@utils/integrations';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import DialogElement from './dialog_element';
import DialogIntroductionText from './dialog_introduction_text';
const getStyleFromTheme = makeStyleSheetFromTheme((theme) => {
return {
container: {
backgroundColor: changeOpacity(theme.centerChannelColor, 0.03),
flex: 1,
},
errorContainer: {
marginTop: 15,
marginLeft: 15,
fontSize: 14,
fontWeight: 'bold',
},
scrollView: {
marginBottom: 20,
marginTop: 10,
},
};
});
type Props = {
config: InteractiveDialogConfig;
componentId: string;
}
const close = () => {
Keyboard.dismiss();
dismissModal();
};
const makeCloseButton = (icon: ImageResource) => {
return buildNavigationButton(CLOSE_BUTTON_ID, 'close.more_direct_messages.button', icon);
};
const CLOSE_BUTTON_ID = 'close-interactive-dialog';
const SUBMIT_BUTTON_ID = 'submit-interactive-dialog';
type Errors = {[name: string]: string}
const emptyErrorsState: Errors = {};
type Values = {[name: string]: string|number|boolean}
type ValuesAction = {name: string; value: string|number|boolean}
function valuesReducer(state: Values, action: ValuesAction) {
if (state[action.name] === action.value) {
return state;
}
return {...state, [action.name]: action.value};
}
function initValues(elements: DialogElement[]) {
const values: Values = {};
elements.forEach((e) => {
if (e.type === 'bool') {
values[e.name] = (e.default === true || String(e.default).toLowerCase() === 'true');
} else if (e.default) {
values[e.name] = e.default;
}
});
return values;
}
const emptyElementList: DialogElement[] = [];
function InteractiveDialog({
config: {
url,
dialog: {
callback_id: callbackId,
introduction_text: introductionText,
elements = emptyElementList,
notify_on_cancel: notifyOnCancel,
state,
submit_label: submitLabel,
},
},
componentId,
}: Props) {
const theme = useTheme();
const style = getStyleFromTheme(theme);
const [error, setError] = useState('');
const [errors, setErrors] = useState(emptyErrorsState);
const [values, dispatchValues] = useReducer(valuesReducer, elements, initValues);
const [submitting, setSubmitting] = useState(false);
const serverUrl = useServerUrl();
const intl = useIntl();
const scrollView = useRef<ScrollView>();
const onChange = useCallback((name: string, value: string | number | boolean) => {
dispatchValues({name, value});
}, []);
const rightButton = useMemo(() => {
const base = buildNavigationButton(
SUBMIT_BUTTON_ID,
'interactive_dialog.submit.button',
undefined,
submitLabel || intl.formatMessage({id: 'interactive_dialog.submit', defaultMessage: 'Submit'}),
);
base.enabled = submitting;
base.showAsAction = 'always';
base.color = theme.sidebarHeaderTextColor;
return base;
}, [theme.sidebarHeaderTextColor, intl]);
useEffect(() => {
setButtons(componentId, {
rightButtons: [rightButton],
});
}, [rightButton, componentId]);
useEffect(() => {
const icon = CompassIcon.getImageSourceSync('close', 24, theme.sidebarHeaderTextColor);
setButtons(componentId, {
leftButtons: [makeCloseButton(icon)],
});
}, [theme.sidebarHeaderTextColor]);
const handleSubmit = useCallback(async () => {
const newErrors: Errors = {};
let hasErrors = false;
if (elements) {
elements.forEach((elem) => {
const newError = checkDialogElementForError(elem, values[elem.name]);
if (newError) {
newErrors[elem.name] = intl.formatMessage({id: newError.id, defaultMessage: newError.defaultMessage}, newError.values);
hasErrors = true;
}
});
}
setErrors(hasErrors ? errors : emptyErrorsState);
if (hasErrors) {
return;
}
const dialog = {
url,
callback_id: callbackId,
state,
submission: values,
} as DialogSubmission;
setSubmitting(true);
const {data} = await submitInteractiveDialog(serverUrl, dialog);
if (data) {
if (data.errors &&
Object.keys(data.errors).length >= 0 &&
checkIfErrorsMatchElements(data.errors, elements)
) {
hasErrors = true;
setErrors(data.errors);
}
if (data.error) {
hasErrors = true;
setError(data.error);
scrollView.current?.scrollTo({x: 0, y: 0});
} else {
setError('');
}
}
if (hasErrors) {
setSubmitting(false);
} else {
close();
}
}, [elements, values, intl, url, callbackId, state]);
useEffect(() => {
const unsubscribe = Navigation.events().registerComponentListener({
navigationButtonPressed: ({buttonId}: { buttonId: string }) => {
switch (buttonId) {
case CLOSE_BUTTON_ID:
if (notifyOnCancel) {
submitInteractiveDialog(serverUrl, {
url,
callback_id: callbackId,
state,
cancelled: true,
} as DialogSubmission);
}
close();
break;
case SUBMIT_BUTTON_ID: {
if (!submitting) {
handleSubmit();
}
break;
}
}
},
}, componentId);
return () => {
unsubscribe.remove();
};
}, [serverUrl, url, callbackId, state, handleSubmit, submitting]);
return (
<SafeAreaView
testID='interactive_dialog.screen'
style={style.container}
>
<ScrollView
// @ts-expect-error legacy ref
ref={scrollView}
style={style.scrollView}
>
{Boolean(error) && (
<ErrorText
testID='interactive_dialog.error.text'
textStyle={style.errorContainer}
error={error}
/>
)}
{Boolean(introductionText) &&
<DialogIntroductionText
value={introductionText}
/>
}
{Boolean(elements) && elements.map((e) => {
return (
<DialogElement
key={'dialogelement' + e.name}
displayName={e.display_name}
name={e.name}
type={e.type}
subtype={e.subtype}
helpText={e.help_text}
errorText={errors[e.name]}
placeholder={e.placeholder}
maxLength={e.max_length}
dataSource={e.data_source}
optional={e.optional}
options={e.options}
value={values[e.name]}
onChange={onChange}
/>
);
})}
</ScrollView>
</SafeAreaView>
);
}
export default InteractiveDialog;

View File

@@ -683,3 +683,8 @@ export async function dismissBottomSheet(alternativeScreen = Screens.BOTTOM_SHEE
DeviceEventEmitter.emit(Events.CLOSE_BOTTOM_SHEET);
await EphemeralStore.waitUntilScreensIsRemoved(alternativeScreen);
}
export const showAppForm = async (form: AppForm, call: AppCallRequest) => {
const passProps = {form, call};
showModal(Screens.APP_FORM, form.title || '', passProps);
};

View File

@@ -1,126 +1,204 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {AppBindingLocations, AppCallResponseTypes} from '@constants/apps';
import {AppBindingLocations, AppCallResponseTypes, AppFieldTypes} from '@constants/apps';
export function copyAndFillBindings(binding?: AppBinding): AppBinding | undefined {
if (!binding) {
return undefined;
}
const copy = JSON.parse(JSON.stringify(binding));
fillAndTrimBindingsInformation(copy);
return copy;
export function cleanBinding(binding: AppBinding, topLocation: string): AppBinding {
return cleanBindingRec(binding, topLocation, 0);
}
// 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) {
function cleanBindingRec(binding: AppBinding, topLocation: string, depth: number): AppBinding {
if (!binding) {
return;
return binding;
}
binding.bindings?.forEach((b) => {
// Propagate id down if not defined
const toRemove: number[] = [];
const usedLabels: {[label: string]: boolean} = {};
binding.bindings?.forEach((b, i) => {
// Inheritance and defaults
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;
if (!b.label) {
b.label = b.location || '';
}
fillAndTrimBindingsInformation(b);
b.location = binding.location + '/' + b.location;
// Validation
if (!b.app_id) {
toRemove.unshift(i);
return;
}
// No empty labels nor "whitespace" labels
if (!b.label.trim()) {
toRemove.unshift(i);
return;
}
switch (topLocation) {
case AppBindingLocations.COMMAND: {
if (b.label.match(/ |\t/)) {
toRemove.unshift(i);
return;
}
if (usedLabels[b.label]) {
toRemove.unshift(i);
return;
}
break;
}
case AppBindingLocations.IN_POST: {
if (usedLabels[b.label]) {
toRemove.unshift(i);
return;
}
break;
}
}
// Must have only subbindings, a form or a submit call.
const hasBindings = Boolean(b.bindings?.length);
const hasForm = Boolean(b.form);
const hasSubmit = Boolean(b.submit);
if ((!hasBindings && !hasForm && !hasSubmit) ||
(hasBindings && hasForm) ||
(hasBindings && hasSubmit) ||
(hasForm && hasSubmit)) {
toRemove.unshift(i);
return;
}
if (hasBindings) {
cleanBindingRec(b, topLocation, depth + 1);
// Remove invalid branches
if (!b.bindings?.length) {
toRemove.unshift(i);
return;
}
} else if (hasForm) {
if (!b.form?.submit && !b.form?.source) {
toRemove.unshift(i);
return;
}
cleanForm(b.form);
}
usedLabels[b.label] = true;
});
// Trim branches without app_id
if (!binding.app_id) {
binding.bindings = binding.bindings?.filter((v) => v.app_id);
}
toRemove.forEach((i) => {
binding.bindings?.splice(i, 1);
});
// 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;
}
return binding;
}
export function validateBindings(binding?: AppBinding) {
filterInvalidChannelHeaderBindings(binding);
filterInvalidCommands(binding);
filterInvalidPostMenuBindings(binding);
binding?.bindings?.forEach(fillAndTrimBindingsInformation);
export function validateBindings(bindings: AppBinding[] = []): AppBinding[] {
const channelHeaderBindings = bindings?.filter((b) => b.location === AppBindingLocations.CHANNEL_HEADER_ICON);
const postMenuBindings = bindings?.filter((b) => b.location === AppBindingLocations.POST_MENU_ITEM);
const commandBindings = bindings?.filter((b) => b.location === AppBindingLocations.COMMAND);
channelHeaderBindings.forEach((b) => cleanBinding(b, AppBindingLocations.CHANNEL_HEADER_ICON));
postMenuBindings.forEach((b) => cleanBinding(b, AppBindingLocations.POST_MENU_ITEM));
commandBindings.forEach((b) => cleanBinding(b, AppBindingLocations.COMMAND));
const hasBindings = (b: AppBinding) => b.bindings?.length;
return postMenuBindings.filter(hasBindings).concat(channelHeaderBindings.filter(hasBindings), commandBindings.filter(hasBindings));
}
// filterInvalidCommands remove commands without a label
function filterInvalidCommands(binding?: AppBinding) {
if (!binding) {
export function cleanForm(form?: AppForm): void {
if (!form) {
return;
}
const isValidCommand = (b: AppBinding): boolean => {
return Boolean(b.label);
};
const toRemove: number[] = [];
const usedLabels: {[label: string]: boolean} = {};
form.fields?.forEach((field, i) => {
if (!field.name) {
toRemove.unshift(i);
return;
}
const validateCommand = (b: AppBinding) => {
b.bindings = b.bindings?.filter(isValidCommand);
b.bindings?.forEach(validateCommand);
};
if (field.name.match(/ |\t/)) {
toRemove.unshift(i);
return;
}
binding.bindings?.filter((b) => b.location === AppBindingLocations.COMMAND).forEach(validateCommand);
let label = field.label;
if (!label) {
label = field.name;
}
if (label.match(/ |\t/)) {
toRemove.unshift(i);
return;
}
if (usedLabels[label]) {
toRemove.unshift(i);
return;
}
switch (field.type) {
case AppFieldTypes.STATIC_SELECT:
cleanStaticSelect(field);
if (!field.options?.length) {
toRemove.unshift(i);
return;
}
break;
case AppFieldTypes.DYNAMIC_SELECT:
if (!field.lookup) {
toRemove.unshift(i);
return;
}
}
usedLabels[label] = true;
});
toRemove.forEach((i) => {
form.fields!.splice(i, 1);
});
}
// filterInvalidChannelHeaderBindings remove bindings
// without a label.
function filterInvalidChannelHeaderBindings(binding?: AppBinding) {
if (!binding) {
return;
}
function cleanStaticSelect(field: AppField): void {
const toRemove: number[] = [];
const usedLabels: {[label: string]: boolean} = {};
const usedValues: {[label: string]: boolean} = {};
field.options?.forEach((option, i) => {
let label = option.label;
if (!label) {
label = option.value;
}
const isValidChannelHeaderBindings = (b: AppBinding): boolean => {
return Boolean(b.label);
};
if (!label) {
toRemove.unshift(i);
return;
}
const validateChannelHeaderBinding = (b: AppBinding) => {
b.bindings = b.bindings?.filter(isValidChannelHeaderBindings);
b.bindings?.forEach(validateChannelHeaderBinding);
};
if (usedLabels[label]) {
toRemove.unshift(i);
return;
}
binding.bindings?.filter((b) => b.location === AppBindingLocations.CHANNEL_HEADER_ICON).forEach(validateChannelHeaderBinding);
}
if (usedValues[option.value]) {
toRemove.unshift(i);
return;
}
// filterInvalidPostMenuBindings remove bindings
// without a label.
function filterInvalidPostMenuBindings(binding?: AppBinding) {
if (!binding) {
return;
}
usedLabels[label] = true;
usedValues[option.value] = true;
});
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);
toRemove.forEach((i) => {
field.options?.splice(i, 1);
});
}
export function createCallContext(
@@ -147,8 +225,6 @@ export function createCallRequest(
defaultExpand: AppExpand = {},
values?: AppCallValues,
rawCommand?: string,
query?: string,
selectedField?: string,
): AppCallRequest {
return {
...call,
@@ -159,15 +235,13 @@ export function createCallRequest(
...call.expand,
},
raw_command: rawCommand,
query,
selected_field: selectedField,
};
}
export const makeCallErrorResponse = (errMessage: string) => {
export const makeCallErrorResponse = (errMessage: string): AppCallResponse<any> => {
return {
type: AppCallResponseTypes.ERROR,
error: errMessage,
text: errMessage,
};
};

103
app/utils/integrations.ts Normal file
View File

@@ -0,0 +1,103 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {KeyboardTypeOptions} from 'react-native';
type DialogError = {
id: string;
defaultMessage: string;
values?: any;
};
export function checkDialogElementForError(elem: DialogElement, value: any): DialogError | undefined | null {
if (!value && !elem.optional) {
return {
id: 'interactive_dialog.error.required',
defaultMessage: 'This field is required.',
};
}
const type = elem.type;
if (type === 'text' || type === 'textarea') {
if (value && value.length < elem.min_length) {
return {
id: 'interactive_dialog.error.too_short',
defaultMessage: 'Minimum input length is {minLength}.',
values: {minLength: elem.min_length},
};
}
if (elem.subtype === 'email') {
if (value && !value.includes('@')) {
return {
id: 'interactive_dialog.error.bad_email',
defaultMessage: 'Must be a valid email address.',
};
}
}
if (elem.subtype === 'number') {
if (value && isNaN(value)) {
return {
id: 'interactive_dialog.error.bad_number',
defaultMessage: 'Must be a number.',
};
}
}
if (elem.subtype === 'url') {
if (value && !value.startsWith('http://') && !value.startsWith('https://')) {
return {
id: 'interactive_dialog.error.bad_url',
defaultMessage: 'URL must include http:// or https://.',
};
}
}
} else if (type === 'radio') {
const options = elem.options;
if (typeof value !== 'undefined' && Array.isArray(options) && !options.some((e) => e.value === value)) {
return {
id: 'interactive_dialog.error.invalid_option',
defaultMessage: 'Must be a valid option',
};
}
}
return null;
}
// If we're returned errors that don't match any of the elements we have,
// ignore them and complete the dialog
export function checkIfErrorsMatchElements(errors: {
[x: string]: DialogError;
} = {}, elements: DialogElement[] = []) {
for (const name in errors) {
if (!errors.hasOwnProperty(name)) {
continue;
}
for (const elem of elements) {
if (elem.name === name) {
return true;
}
}
}
return false;
}
export function selectKeyboardType(subtype?: string): KeyboardTypeOptions {
switch (subtype) {
case 'email':
return 'email-address';
case 'number':
return 'numeric';
case 'tel':
return 'phone-pad';
case 'url':
return 'url';
default:
return 'default';
}
}

View File

@@ -1,23 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export function getDistanceBW2Points(point1, point2, xAttr = 'x', yAttr = 'y') {
return Math.sqrt(Math.pow(point1[xAttr] - point2[xAttr], 2) + Math.pow(point1[yAttr] - point2[yAttr], 2));
}
/**
* Funtion to return nearest point of given pivot point.
* It return two points one nearest and other nearest but having both coorditanes smaller than the given point's coordinates.
*/
export function getNearestPoint(pivotPoint, points, xAttr = 'x', yAttr = 'y') {
let nearestPoint = {};
for (const point of points) {
if (typeof nearestPoint[xAttr] === 'undefined' || typeof nearestPoint[yAttr] === 'undefined') {
nearestPoint = point;
} else if (getDistanceBW2Points(point, pivotPoint, xAttr, yAttr) < getDistanceBW2Points(nearestPoint, pivotPoint, xAttr, yAttr)) {
// Check for bestImage
nearestPoint = point;
}
}
return nearestPoint;
}

View File

@@ -20,32 +20,49 @@
"api.channel.guest_join_channel.post_and_forget": "{username} joined the channel as a guest.",
"apps.error": "Error: {error}",
"apps.error.command.field_missing": "Required fields missing: `{fieldName}`.",
"apps.error.command.same_channel": "Channel repeated for field `{fieldName}`: `{option}`.",
"apps.error.command.same_option": "Option repeated for field `{fieldName}`: `{option}`.",
"apps.error.command.same_user": "User repeated for field `{fieldName}`: `{option}`.",
"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_form": "`form` is not defined.",
"apps.error.form.no_lookup": "`lookup` is not defined.",
"apps.error.form.no_source": "`source` is not defined.",
"apps.error.form.no_submit": "`submit` 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: {errorMessage}",
"apps.error.malformed_binding": "This binding is not properly formed. Contact the App developer.",
"apps.error.parser": "Parsing error: {error}",
"apps.error.parser.empty_value": "Empty values are not allowed.",
"apps.error.parser.execute_non_leaf": "You must select a subcommand.",
"apps.error.parser.missing_binding": "Missing command bindings.",
"apps.error.parser.missing_call": "Missing binding call.",
"apps.error.parser.missing_field_value": "Field value is missing.",
"apps.error.parser.missing_list_end": "Expected list closing token.",
"apps.error.parser.missing_quote": "Matching double quote expected before end of input.",
"apps.error.parser.missing_source": "Form has neither submit nor source.",
"apps.error.parser.missing_submit": "No submit call in binding or form.",
"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": "Unable to identify argument.",
"apps.error.parser.no_bindings": "No command bindings.",
"apps.error.parser.no_form": "No form found.",
"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_character": "Unexpected character.",
"apps.error.parser.unexpected_comma": "Unexpected comma.",
"apps.error.parser.unexpected_error": "Unexpected error.",
"apps.error.parser.unexpected_flag": "Command does not accept flag `{flagName}`.",
"apps.error.parser.unexpected_squared_bracket": "Unexpected list opening.",
"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 the 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_field_error": "Received an error for an unknown field. Field name: `{field}`. Error: `{error}`.",
"apps.error.responses.unknown_type": "App response type not supported. Response type: {type}.",
"apps.error.unknown": "Unknown error occurred.",
"apps.suggestion.dynamic.error": "Dynamic select error",
@@ -54,6 +71,7 @@
"apps.suggestion.no_static": "No matching options.",
"apps.suggestion.no_suggestion": "No matching suggestions.",
"archivedChannelMessage": "You are viewing an **archived channel**. New messages cannot be posted.",
"autocomplete_selector.unknown_channel": "Unknown channel",
"browse_channels.archivedChannels": "Archived Channels",
"browse_channels.dropdownTitle": "Show",
"browse_channels.noMore": "No more channels to join",
@@ -206,6 +224,7 @@
"global_threads.options.unfollow": "Unfollow Thread",
"global_threads.unreads": "Unread Threads",
"home.header.plus_menu": "Options",
"interactive_dialog.submit": "Submit",
"intro.add_people": "Add People",
"intro.channel_details": "Details",
"intro.created_by": "created by {creator} on {date}.",

63
types/api/apps.d.ts vendored
View File

@@ -6,16 +6,20 @@ type AppManifest = {
display_name: string;
description?: string;
homepage_url?: string;
root_url: string;
};
}
type AppModalState = {
form: AppForm;
call: AppCallRequest;
};
}
type AppsState = {
bindings: AppBinding[];
bindingsForms: AppCommandFormMap;
threadBindings: AppBinding[];
threadBindingsForms: AppCommandFormMap;
threadBindingsChannelId: string;
pluginEnabled: boolean;
};
type AppBinding = {
@@ -44,19 +48,17 @@ type AppBinding = {
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;
// A Binding is either an action (makes a call), a Form, or is a
// "container" for other locations - i.e. menu sub-items or subcommands.
bindings?: AppBinding[];
form?: AppForm;
submit?: AppCall;
};
type AppCallValues = {
[name: string]: any;
};
type AppCallType = string;
type AppCall = {
path: string;
expand?: AppExpand;
@@ -75,9 +77,8 @@ type AppCallResponseType = string;
type AppCallResponse<Res = unknown> = {
type: AppCallResponseType;
markdown?: string;
text?: string;
data?: Res;
error?: string;
navigate_to_url?: string;
use_external_browser?: boolean;
call?: AppCall;
@@ -101,6 +102,7 @@ type AppContext = {
root_id?: string;
props?: AppContextProps;
user_agent?: string;
track_as_submit?: boolean;
};
type AppContextProps = {
@@ -130,12 +132,23 @@ type AppForm = {
submit_buttons?: string;
cancel_button?: boolean;
submit_on_cancel?: boolean;
fields: AppField[];
call?: AppCall;
fields?: AppField[];
// source is used in 2 cases:
// - if submit is not set, it is used to fetch the submittable form from
// the app.
// - if a select field change triggers a refresh, the form is refreshed
// from source.
source?: AppCall;
// submit is called when one of the submit buttons is pressed, or the
// command is executed.
submit?: AppCall;
depends_on?: string[];
};
type AppFormValue = string | AppSelectOption | boolean | null;
type AppFormValue = string | boolean | number | AppSelectOption | AppSelectOption[];
type AppFormValues = {[name: string]: AppFormValue};
type AppSelectOption = {
@@ -170,6 +183,7 @@ type AppField = {
refresh?: boolean;
options?: AppSelectOption[];
multiselect?: boolean;
lookup?: AppCall;
// Text props
subtype?: string;
@@ -177,18 +191,6 @@ type AppField = {
max_length?: number;
};
type AutocompleteSuggestion = {
Suggestion: string;
Complete: string;
Description: string;
Hint: string;
IconData: string;
};
type AutocompleteSuggestionWithComplete = AutocompleteSuggestion & {
complete: string;
};
type AutocompleteElement = AppField;
type AutocompleteStaticSelect = AutocompleteElement & {
options: AppSelectOption[];
@@ -204,8 +206,15 @@ type FormResponseData = {
errors?: {
[field: string]: string;
};
};
}
type AppLookupResponse = {
items: AppSelectOption[];
};
}
type AppCommandFormMap = {[location: string]: AppForm}
type DoAppCallResult<Res=unknown> = {
data?: AppCallResponse<Res>;
error?: AppCallResponse<Res>;
}

View File

@@ -59,9 +59,9 @@ type DialogOption = {
type DialogElement = {
display_name: string;
name: string;
type: string;
subtype: string;
default: string;
type: InteractiveDialogElementType;
subtype: InteractiveDialogTextSubtype;
default: string | boolean;
placeholder: string;
help_text: string;
optional: boolean;
@@ -114,3 +114,6 @@ type PostActionResponse = {
status: string;
trigger_id: string;
};
type InteractiveDialogElementType = 'text' | 'textarea' | 'select' | 'radio' | 'bool'
type InteractiveDialogTextSubtype = 'email' | 'number' | 'tel' | 'url' | 'password'