[MM-35617] App modals are now opened from components instead of actions (#5394)

* proper fix for opening app form modal and url navigation

* complete merge with changes

* add useCallback check for theme

* lint
This commit is contained in:
Michael Kochell 2021-08-06 11:21:31 -04:00 committed by GitHub
parent 7cdbe095a1
commit fd0cc90160
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 149 additions and 97 deletions

View file

@ -3,22 +3,15 @@
import {sendEphemeralPost} from '@actions/views/post';
import {Client4} from '@client/rest';
import CompassIcon from '@components/compass_icon';
import {handleGotoLocation} from '@mm-redux/actions/integrations';
import {AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {ActionFunc, DispatchFunc} from '@mm-redux/types/actions';
import {AppCallResponse, AppForm, AppCallRequest, AppCallType, AppContext} from '@mm-redux/types/apps';
import {AppCallResponse, AppCallRequest, AppCallType, AppContext} from '@mm-redux/types/apps';
import {CommandArgs} from '@mm-redux/types/integrations';
import {Post} from '@mm-redux/types/posts';
import {Theme} from '@mm-redux/types/preferences';
import EphemeralStore from '@store/ephemeral_store';
import {makeCallErrorResponse} from '@utils/apps';
import {showModal} from './navigation';
export function doAppCall<Res=unknown>(call: AppCallRequest, type: AppCallType, intl: any): ActionFunc {
return async (dispatch, getState) => {
return async () => {
try {
const res = await Client4.executeAppCall(call, type) as AppCallResponse<Res>;
const responseType = res.type || AppCallResponseTypes.OK;
@ -37,11 +30,6 @@ export function doAppCall<Res=unknown>(call: AppCallRequest, type: AppCallType,
return {error: makeCallErrorResponse(errMsg)};
}
const screen = EphemeralStore.getNavigationTopComponentId();
if (type === AppCallTypes.SUBMIT && screen !== 'AppForm') {
showAppForm(res.form, call, getTheme(getState()));
}
return {data: res};
}
case AppCallResponseTypes.NAVIGATE:
@ -61,8 +49,6 @@ export function doAppCall<Res=unknown>(call: AppCallRequest, type: AppCallType,
return {error: makeCallErrorResponse(errMsg)};
}
dispatch(handleGotoLocation(res.navigate_to_url, intl));
return {data: res};
default: {
const errMsg = intl.formatMessage({
@ -84,34 +70,6 @@ export function doAppCall<Res=unknown>(call: AppCallRequest, type: AppCallType,
};
}
const showAppForm = async (form: AppForm, call: AppCallRequest, theme: Theme) => {
const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
let submitButtons;
const customSubmitButtons = form.submit_buttons && form.fields.find((f) => f.name === form.submit_buttons)?.options;
if (!customSubmitButtons?.length) {
submitButtons = [{
id: 'submit-form',
showAsAction: 'always',
text: 'Submit',
}];
}
const options = {
topBar: {
leftButtons: [{
id: 'close-dialog',
icon: closeButton,
}],
rightButtons: submitButtons,
},
};
const passProps = {form, call};
showModal('AppForm', form.title, passProps, options);
};
export function postEphemeralCallResponseForPost(response: AppCallResponse, message: string, post: Post): ActionFunc {
return (dispatch: DispatchFunc) => {
return dispatch(sendEphemeralPost(

View file

@ -5,6 +5,7 @@ import merge from 'deepmerge';
import {Keyboard, Platform} from 'react-native';
import {Navigation} from 'react-native-navigation';
import CompassIcon from '@components/compass_icon';
import {DeviceTypes, NavigationTypes} from '@constants';
import {CHANNEL} from '@constants/screen';
import {Preferences} from '@mm-redux/constants';
@ -372,6 +373,34 @@ export function showSearchModal(initialValue = '') {
showModal(name, title, passProps, options);
}
export const showAppForm = async (form, call, theme) => {
const closeButton = await CompassIcon.getImageSource('close', 24, theme.sidebarHeaderTextColor);
let submitButtons;
const customSubmitButtons = form.submit_buttons && form.fields.find((f) => f.name === form.submit_buttons)?.options;
if (!customSubmitButtons?.length) {
submitButtons = [{
id: 'submit-form',
showAsAction: 'always',
text: 'Submit',
}];
}
const options = {
topBar: {
leftButtons: [{
id: 'close-dialog',
icon: closeButton,
}],
rightButtons: submitButtons,
},
};
const passProps = {form, call};
showModal('AppForm', form.title, passProps, options);
};
export async function dismissModal(options = {}) {
if (!EphemeralStore.hasModalsOpened()) {
return;

View file

@ -67,8 +67,14 @@ export function executeCommand(message: string, channelId: string, rootId: strin
}
return {data: {}};
case AppCallResponseTypes.FORM:
return {data: {
form: callResp.form,
call,
}};
case AppCallResponseTypes.NAVIGATE:
return {data: {}};
return {data: {
goto_location: callResp.navigate_to_url,
}};
default:
return createErrorMessage(intl.formatMessage({
id: 'apps.error.responses.unknown_type',

View file

@ -8,6 +8,7 @@ import {Platform, ScrollView, View} from 'react-native';
import HWKeyboardEvent from 'react-native-hw-keyboard-event';
import {SafeAreaView} from 'react-native-safe-area-context';
import {showAppForm} from '@actions/navigation';
import Autocomplete from '@components/autocomplete';
import PostInput from '@components/post_draft/post_input';
import QuickActions from '@components/post_draft/quick_actions';
@ -294,7 +295,7 @@ export default class DraftInput extends PureComponent {
sendCommand = async (msg) => {
const {intl} = this.context;
const {channelId, executeCommand, rootId, userIsOutOfOffice} = this.props;
const {channelId, executeCommand, rootId, userIsOutOfOffice, theme} = this.props;
const status = DraftUtils.getStatusFromSlashCommand(msg);
if (userIsOutOfOffice && DraftUtils.isStatusSlashCommand(status)) {
@ -312,6 +313,10 @@ export default class DraftInput extends PureComponent {
return;
}
if (data.form) {
showAppForm(data.form, data.call, theme);
}
this.setInputValue('');
this.input.current.changeDraft('');

View file

@ -5,7 +5,9 @@ import React, {useCallback, useRef} from 'react';
import {intlShape, injectIntl} from 'react-intl';
import Button from 'react-native-button';
import {showAppForm} from '@actions/navigation';
import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {ActionResult} from '@mm-redux/types/actions';
import {AppBinding} from '@mm-redux/types/apps';
import {Post} from '@mm-redux/types/posts';
import {Theme} from '@mm-redux/types/preferences';
@ -23,6 +25,7 @@ type Props = {
intl: typeof intlShape;
post: Post;
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
handleGotoLocation: (href: string, intl: any) => Promise<ActionResult>;
teamID: string;
theme: Theme;
}
@ -50,7 +53,7 @@ const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => {
};
});
const ButtonBinding = ({binding, doAppCall, intl, post, postEphemeralCallResponseForPost, teamID, theme}: Props) => {
const ButtonBinding = ({binding, doAppCall, intl, post, postEphemeralCallResponseForPost, teamID, theme, handleGotoLocation}: Props) => {
const pressed = useRef(false);
const style = getStyleSheet(theme);
@ -97,7 +100,10 @@ const ButtonBinding = ({binding, doAppCall, intl, post, postEphemeralCallRespons
}
return;
case AppCallResponseTypes.NAVIGATE:
handleGotoLocation(callResp.navigate_to_url!, intl);
return;
case AppCallResponseTypes.FORM:
showAppForm(callResp.form, call, theme);
return;
default: {
const errorMessage = intl.formatMessage({
@ -109,7 +115,7 @@ const ButtonBinding = ({binding, doAppCall, intl, post, postEphemeralCallRespons
postEphemeralCallResponseForPost(callResp, errorMessage, post);
}
}
}), []);
}), [theme]);
return (
<Button

View file

@ -4,6 +4,7 @@
import {connect} from 'react-redux';
import {doAppCall, postEphemeralCallResponseForPost} from '@actions/apps';
import {handleGotoLocation} from '@mm-redux/actions/integrations';
import {getChannel} from '@mm-redux/selectors/entities/channels';
import {getPost} from '@mm-redux/selectors/entities/posts';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
@ -33,6 +34,7 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const mapDispatchToProps = {
doAppCall,
postEphemeralCallResponseForPost,
handleGotoLocation,
};
export default connect(mapStateToProps, mapDispatchToProps)(ButtonBinding);

View file

@ -4,8 +4,10 @@
import {connect} from 'react-redux';
import {doAppCall, postEphemeralCallResponseForPost} from '@actions/apps';
import {handleGotoLocation} from '@mm-redux/actions/integrations';
import {getChannel} from '@mm-redux/selectors/entities/channels';
import {getPost} from '@mm-redux/selectors/entities/posts';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {getCurrentTeamId} from '@mm-redux/selectors/entities/teams';
import MenuBinding from './menu_binding';
@ -23,6 +25,7 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const channel = getChannel(state, post.channel_id);
return {
theme: getTheme(state),
post,
teamID: channel?.team_id || getCurrentTeamId(state),
};
@ -31,6 +34,7 @@ function mapStateToProps(state: GlobalState, ownProps: OwnProps) {
const mapDispatchToProps = {
doAppCall,
postEphemeralCallResponseForPost,
handleGotoLocation,
};
export default connect(mapStateToProps, mapDispatchToProps)(MenuBinding);

View file

@ -4,8 +4,11 @@
import React, {useCallback, useState} from 'react';
import {intlShape, injectIntl} from 'react-intl';
import {showAppForm} from '@actions/navigation';
import AutocompleteSelector from '@components/autocomplete_selector';
import {AppExpandLevels, AppBindingLocations, AppCallTypes, AppCallResponseTypes} from '@mm-redux/constants/apps';
import {ActionResult} from '@mm-redux/types/actions';
import {Theme} from '@mm-redux/types/preferences';
import {createCallContext, createCallRequest} from '@utils/apps';
import type {AppBinding} from '@mm-redux/types/apps';
@ -19,10 +22,12 @@ type Props = {
intl: typeof intlShape;
post: Post;
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
handleGotoLocation: (href: string, intl: any) => Promise<ActionResult>;
teamID: string;
theme: Theme;
}
const MenuBinding = ({binding, doAppCall, intl, post, postEphemeralCallResponseForPost, teamID}: Props) => {
const MenuBinding = ({binding, doAppCall, intl, post, postEphemeralCallResponseForPost, handleGotoLocation, teamID, theme}: Props) => {
const [selected, setSelected] = useState<PostActionOption>();
const onSelect = useCallback(async (picked?: PostActionOption) => {
@ -74,7 +79,10 @@ const MenuBinding = ({binding, doAppCall, intl, post, postEphemeralCallResponseF
}
return;
case AppCallResponseTypes.NAVIGATE:
handleGotoLocation(callResp.navigate_to_url!, intl);
return;
case AppCallResponseTypes.FORM:
showAppForm(callResp.form, call, theme);
return;
default: {
const errorMessage = intl.formatMessage({
@ -86,7 +94,7 @@ const MenuBinding = ({binding, doAppCall, intl, post, postEphemeralCallResponseF
postEphemeralCallResponseForPost(callResp, errorMessage, post);
}
}
}, []);
}, [theme]);
const options = binding.bindings?.map<PostActionOption>((b:AppBinding) => {
return {text: b.label, value: b.location || ''};

View file

@ -15,6 +15,7 @@ describe('AppsForm', () => {
performLookupCall: jest.fn(),
refreshOnSelect: jest.fn(),
submit: jest.fn(),
handleGotoLocation: jest.fn(),
},
call: {
context: {

View file

@ -13,6 +13,7 @@ import {dismissModal} from '@actions/navigation';
import Markdown from '@components/markdown';
import StatusBar from '@components/status_bar';
import {AppCallResponseTypes, AppFieldTypes} from '@mm-redux/constants/apps';
import {ActionResult} from '@mm-redux/types/actions';
import {AppCallRequest, AppField, AppForm, AppFormValue, AppFormValues, AppLookupResponse, AppSelectOption, FormResponseData} from '@mm-redux/types/apps';
import {DialogElement} from '@mm-redux/types/integrations';
import {Theme} from '@mm-redux/types/preferences';
@ -35,6 +36,7 @@ export type Props = {
}) => Promise<DoAppCallResult<FormResponseData>>;
performLookupCall: (field: AppField, values: AppFormValues, userInput: string) => Promise<DoAppCallResult<AppLookupResponse>>;
refreshOnSelect: (field: AppField, values: AppFormValues, value: AppFormValue) => Promise<DoAppCallResult<FormResponseData>>;
handleGotoLocation: (href: string, intl: any) => Promise<ActionResult>;
};
theme: Theme;
componentId: string;
@ -166,7 +168,8 @@ export default class AppsFormComponent extends PureComponent<Props, State> {
switch (callResponse.type) {
case AppCallResponseTypes.OK:
case AppCallResponseTypes.NAVIGATE:
this.handleHide();
await this.handleHide();
this.props.actions.handleGotoLocation(callResponse.navigate_to_url!, this.context.intl);
return;
case AppCallResponseTypes.FORM:
this.submitting = false;
@ -280,8 +283,8 @@ export default class AppsFormComponent extends PureComponent<Props, State> {
}
}
handleHide = () => {
dismissModal();
handleHide = async () => {
await dismissModal();
}
onChange = (name: string, value: any) => {

View file

@ -5,6 +5,7 @@ import React, {PureComponent} from 'react';
import {intlShape} from 'react-intl';
import {AppCallResponseTypes, AppCallTypes} from '@mm-redux/constants/apps';
import {ActionResult} from '@mm-redux/types/actions';
import {AppCallResponse, AppCallRequest, AppField, AppForm, AppFormValues, FormResponseData, AppLookupResponse} from '@mm-redux/types/apps';
import {Theme} from '@mm-redux/types/preferences';
import {DoAppCall, DoAppCallResult, PostEphemeralCallResponseForContext} from '@mm-types/actions/apps';
@ -18,6 +19,7 @@ export type Props = {
actions: {
doAppCall: DoAppCall<any>;
postEphemeralCallResponseForContext: PostEphemeralCallResponseForContext;
handleGotoLocation: (href: string, intl: any) => Promise<ActionResult>;
};
theme: Theme;
componentId: string;
@ -233,6 +235,7 @@ export default class AppsFormContainer extends PureComponent<Props, State> {
submit: this.handleSubmit,
performLookupCall: this.performLookupCall,
refreshOnSelect: this.refreshOnSelect,
handleGotoLocation: this.props.actions.handleGotoLocation,
}}
theme={this.props.theme}
componentId={this.props.componentId}

View file

@ -5,6 +5,7 @@ import {connect} from 'react-redux';
import {ActionCreatorsMapObject, bindActionCreators, Dispatch} from 'redux';
import {doAppCall, postEphemeralCallResponseForContext} from '@actions/apps';
import {handleGotoLocation} from '@mm-redux/actions/integrations';
import {getTheme} from '@mm-redux/selectors/entities/preferences';
import {ActionFunc, GenericAction} from '@mm-redux/types/actions';
import {GlobalState} from '@mm-redux/types/store';
@ -28,6 +29,7 @@ function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
doAppCall,
postEphemeralCallResponseForContext,
handleGotoLocation,
}, dispatch),
};
}

View file

@ -5,8 +5,9 @@ import React from 'react';
import {intlShape, injectIntl} from 'react-intl';
import {Alert} from 'react-native';
import {dismissModal} from '@actions/navigation';
import {dismissModal, showAppForm} from '@actions/navigation';
import {AppCallResponseTypes, AppCallTypes} from '@mm-redux/constants/apps';
import {ActionResult} from '@mm-redux/types/actions';
import {AppBinding} from '@mm-redux/types/apps';
import {Channel} from '@mm-redux/types/channels';
import {Theme} from '@mm-redux/types/preferences';
@ -26,6 +27,7 @@ type Props = {
actions: {
doAppCall: DoAppCall;
postEphemeralCallResponseForChannel: PostEphemeralCallResponseForChannel;
handleGotoLocation: (href: string, intl: any) => Promise<ActionResult>;
};
}
@ -65,6 +67,7 @@ type OptionProps = {
actions: {
doAppCall: DoAppCall;
postEphemeralCallResponseForChannel: PostEphemeralCallResponseForChannel;
handleGotoLocation: (href: string, intl: any) => Promise<ActionResult>;
};
}
@ -78,7 +81,7 @@ class Option extends React.PureComponent<OptionProps, OptionState> {
};
onPress = async () => {
const {binding, currentChannel, currentTeamId, intl} = this.props;
const {binding, currentChannel, currentTeamId, intl, theme} = this.props;
const {doAppCall, postEphemeralCallResponseForChannel} = this.props.actions;
if (this.state.submitting) {
@ -128,8 +131,13 @@ class Option extends React.PureComponent<OptionProps, OptionState> {
}
break;
case AppCallResponseTypes.NAVIGATE:
await dismissModal();
this.props.actions.handleGotoLocation(callResp.navigate_to_url!, intl);
return;
case AppCallResponseTypes.FORM:
break;
await dismissModal();
showAppForm(callResp.form, call, theme);
return;
default: {
const title = intl.formatMessage({
id: 'mobile.general.error.title',

View file

@ -5,6 +5,7 @@ import {connect} from 'react-redux';
import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux';
import {doAppCall, postEphemeralCallResponseForChannel} from '@actions/apps';
import {handleGotoLocation} from '@mm-redux/actions/integrations';
import {AppBindingLocations} from '@mm-redux/constants/apps';
import {getAppsBindings} from '@mm-redux/selectors/entities/apps';
import {getCurrentChannel} from '@mm-redux/selectors/entities/channels';
@ -39,6 +40,7 @@ function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
doAppCall,
postEphemeralCallResponseForChannel,
handleGotoLocation,
}, dispatch),
};
}

View file

@ -5,7 +5,9 @@ import React from 'react';
import {intlShape, injectIntl} from 'react-intl';
import {Alert} from 'react-native';
import {showAppForm} from '@actions/navigation';
import {AppCallResponseTypes, AppCallTypes, AppExpandLevels} from '@mm-redux/constants/apps';
import {ActionResult} from '@mm-redux/types/actions';
import {AppBinding, AppCallResponse} from '@mm-redux/types/apps';
import {Post} from '@mm-redux/types/posts';
import {Theme} from '@mm-redux/types/preferences';
@ -28,6 +30,7 @@ type Props = {
actions: {
doAppCall: DoAppCall;
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
handleGotoLocation: (href: string, intl: any) => Promise<ActionResult>;
};
}
@ -74,12 +77,13 @@ type OptionProps = {
actions: {
doAppCall: DoAppCall;
postEphemeralCallResponseForPost: PostEphemeralCallResponseForPost;
handleGotoLocation: (href: string, intl: any) => Promise<ActionResult>;
};
}
class Option extends React.PureComponent<OptionProps> {
onPress = async () => {
const {closeWithAnimation, post, teamID, binding, intl} = this.props;
const {post, teamID, binding, intl, theme} = this.props;
const {doAppCall, postEphemeralCallResponseForPost} = this.props.actions;
if (!binding.call) {
@ -101,50 +105,59 @@ class Option extends React.PureComponent<OptionProps> {
},
);
closeWithAnimation(async () => {
const callPromise = doAppCall(call, AppCallTypes.SUBMIT, intl);
const res = await callPromise;
if (res.error) {
const errorResponse = res.error;
const title = intl.formatMessage({
id: 'mobile.general.error.title',
defaultMessage: 'Error',
});
const errorMessage = errorResponse.error || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
Alert.alert(title, errorMessage);
return;
}
const callPromise = doAppCall(call, AppCallTypes.SUBMIT, intl);
await this.close();
const callResp = (res as {data: AppCallResponse}).data;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
postEphemeralCallResponseForPost(callResp, callResp.markdown, post);
}
break;
case AppCallResponseTypes.NAVIGATE:
case AppCallResponseTypes.FORM:
break;
default: {
const title = intl.formatMessage({
id: 'mobile.general.error.title',
defaultMessage: 'Error',
});
const errMessage = intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
});
Alert.alert(title, errMessage);
const res = await callPromise;
if (res.error) {
const errorResponse = res.error;
const title = intl.formatMessage({
id: 'mobile.general.error.title',
defaultMessage: 'Error',
});
const errorMessage = errorResponse.error || intl.formatMessage({
id: 'apps.error.unknown',
defaultMessage: 'Unknown error occurred.',
});
Alert.alert(title, errorMessage);
return;
}
const callResp = (res as {data: AppCallResponse}).data;
switch (callResp.type) {
case AppCallResponseTypes.OK:
if (callResp.markdown) {
postEphemeralCallResponseForPost(callResp, callResp.markdown, post);
}
}
});
break;
case AppCallResponseTypes.NAVIGATE:
this.props.actions.handleGotoLocation(callResp.navigate_to_url!, intl);
break;
case AppCallResponseTypes.FORM:
showAppForm(callResp.form, call, theme);
break;
default: {
const title = intl.formatMessage({
id: 'mobile.general.error.title',
defaultMessage: 'Error',
});
const errMessage = intl.formatMessage({
id: 'apps.error.responses.unknown_type',
defaultMessage: 'App response type not supported. Response type: {type}.',
}, {
type: callResp.type,
});
Alert.alert(title, errMessage);
}
}
};
close = (): Promise<void> => {
return new Promise((resolve) => {
this.props.closeWithAnimation(resolve);
});
}
render() {
const {binding, theme} = this.props;
if (!binding.label) {

View file

@ -5,6 +5,7 @@ import {connect} from 'react-redux';
import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux';
import {doAppCall, postEphemeralCallResponseForPost} from '@actions/apps';
import {handleGotoLocation} from '@mm-redux/actions/integrations';
import {AppBindingLocations} from '@mm-redux/constants/apps';
import {getAppsBindings} from '@mm-redux/selectors/entities/apps';
import {getChannel} from '@mm-redux/selectors/entities/channels';
@ -48,6 +49,7 @@ function mapDispatchToProps(dispatch: Dispatch<GenericAction>) {
actions: bindActionCreators<ActionCreatorsMapObject<ActionFunc>, Actions>({
doAppCall,
postEphemeralCallResponseForPost,
handleGotoLocation,
}, dispatch),
};
}