forked from Ivasoft/mattermost-mobile
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:
committed by
GitHub
parent
a2fac160ef
commit
e047106bac
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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};
|
||||
|
||||
62
app/actions/remote/integrations.ts
Normal file
62
app/actions/remote/integrations.ts
Normal 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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
26
app/actions/websocket/integrations.ts
Normal file
26
app/actions/websocket/integrations.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
107
app/components/settings/bool_setting.tsx
Normal file
107
app/components/settings/bool_setting.tsx
Normal 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;
|
||||
89
app/components/settings/footer.tsx
Normal file
89
app/components/settings/footer.tsx
Normal 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;
|
||||
68
app/components/settings/label.tsx
Normal file
68
app/components/settings/label.tsx
Normal 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;
|
||||
88
app/components/settings/radio_setting/index.tsx
Normal file
88
app/components/settings/radio_setting/index.tsx
Normal 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;
|
||||
80
app/components/settings/radio_setting/radio_entry.tsx
Normal file
80
app/components/settings/radio_setting/radio_entry.tsx
Normal 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;
|
||||
132
app/components/settings/text_setting.tsx
Normal file
132
app/components/settings/text_setting.tsx
Normal 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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
8
app/constants/integrations.ts
Normal file
8
app/constants/integrations.ts
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
443
app/screens/apps_form/apps_form_component.tsx
Normal file
443
app/screens/apps_form/apps_form_component.tsx
Normal 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;
|
||||
173
app/screens/apps_form/apps_form_field.tsx
Normal file
173
app/screens/apps_form/apps_form_field.tsx
Normal 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;
|
||||
203
app/screens/apps_form/index.tsx
Normal file
203
app/screens/apps_form/index.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
130
app/screens/interactive_dialog/dialog_element.tsx
Normal file
130
app/screens/interactive_dialog/dialog_element.tsx
Normal 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;
|
||||
50
app/screens/interactive_dialog/dialog_introduction_text.tsx
Normal file
50
app/screens/interactive_dialog/dialog_introduction_text.tsx
Normal 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;
|
||||
268
app/screens/interactive_dialog/index.tsx
Normal file
268
app/screens/interactive_dialog/index.tsx
Normal 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;
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
103
app/utils/integrations.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
63
types/api/apps.d.ts
vendored
@@ -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>;
|
||||
}
|
||||
|
||||
9
types/api/integrations.d.ts
vendored
9
types/api/integrations.d.ts
vendored
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user