* proper fix for opening app form modal and url navigation * complete merge with changes * add useCallback check for theme * lint
439 lines
15 KiB
TypeScript
439 lines
15 KiB
TypeScript
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
|
// See LICENSE.txt for license information.
|
|
|
|
import React, {PureComponent} from 'react';
|
|
import {intlShape} from 'react-intl';
|
|
import {ScrollView, Text, View} from 'react-native';
|
|
import Button from 'react-native-button';
|
|
import {EventSubscription, Navigation} from 'react-native-navigation';
|
|
import {SafeAreaView} from 'react-native-safe-area-context';
|
|
import {DoAppCallResult} from 'types/actions/apps';
|
|
|
|
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';
|
|
import {checkDialogElementForError, checkIfErrorsMatchElements} from '@mm-redux/utils/integration_utils';
|
|
import {getMarkdownBlockStyles, getMarkdownTextStyles} from '@utils/markdown';
|
|
import {preventDoubleTap} from '@utils/tap';
|
|
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
|
|
|
|
import {GlobalStyles} from 'app/styles';
|
|
|
|
import AppsFormField from './apps_form_field';
|
|
import DialogIntroductionText from './dialog_introduction_text';
|
|
|
|
export type Props = {
|
|
call: AppCallRequest;
|
|
form: AppForm;
|
|
actions: {
|
|
submit: (submission: {
|
|
values: AppFormValues;
|
|
}) => 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;
|
|
}
|
|
|
|
export type State = {
|
|
values: AppFormValues;
|
|
formError: string | null;
|
|
fieldErrors: {[name: string]: string};
|
|
form: AppForm;
|
|
}
|
|
|
|
const initFormValues = (form: AppForm): AppFormValues => {
|
|
const values: AppFormValues = {};
|
|
if (form && form.fields) {
|
|
form.fields.forEach((f) => {
|
|
let defaultValue: AppFormValue = null;
|
|
if (f.type === AppFieldTypes.BOOL) {
|
|
defaultValue = false;
|
|
}
|
|
|
|
values[f.name] = f.value || defaultValue;
|
|
});
|
|
}
|
|
|
|
return values;
|
|
};
|
|
|
|
export default class AppsFormComponent extends PureComponent<Props, State> {
|
|
private scrollView: React.RefObject<ScrollView>;
|
|
navigationEventListener?: EventSubscription;
|
|
|
|
private submitting = false;
|
|
static contextTypes = {
|
|
intl: intlShape.isRequired,
|
|
};
|
|
|
|
constructor(props: Props) {
|
|
super(props);
|
|
|
|
const {form} = props;
|
|
const values = initFormValues(form);
|
|
|
|
this.state = {
|
|
values,
|
|
formError: null,
|
|
fieldErrors: {},
|
|
form,
|
|
};
|
|
|
|
this.scrollView = React.createRef();
|
|
}
|
|
|
|
static getDerivedStateFromProps(nextProps: Props, prevState: State) {
|
|
if (nextProps.form !== prevState.form) {
|
|
return {
|
|
values: initFormValues(nextProps.form),
|
|
form: nextProps.form,
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.navigationEventListener = Navigation.events().bindComponent(this);
|
|
}
|
|
|
|
navigationButtonPressed({buttonId}: {buttonId: string}) {
|
|
switch (buttonId) {
|
|
case 'submit-form':
|
|
this.handleSubmit();
|
|
return;
|
|
case 'close-dialog':
|
|
this.handleHide();
|
|
}
|
|
}
|
|
|
|
doSubmit = async (button?: string) => {
|
|
if (this.submitting) {
|
|
return;
|
|
}
|
|
|
|
const {fields} = this.props.form;
|
|
const values = this.state.values;
|
|
const fieldErrors: {[name: string]: string} = {};
|
|
|
|
const elements = fieldsAsElements(fields);
|
|
elements?.forEach((element) => {
|
|
const error = checkDialogElementForError( // TODO: make sure all required values are present in `element`
|
|
element,
|
|
values[element.name],
|
|
);
|
|
if (error) {
|
|
fieldErrors[element.name] = this.context.intl.formatMessage(error.id, error.defaultMessage, error.values);
|
|
}
|
|
});
|
|
|
|
this.setState({fieldErrors});
|
|
|
|
if (Object.keys(fieldErrors).length !== 0) {
|
|
return;
|
|
}
|
|
|
|
const submission = {
|
|
values,
|
|
};
|
|
|
|
if (button && this.props.form.submit_buttons) {
|
|
submission.values[this.props.form.submit_buttons] = button;
|
|
}
|
|
|
|
this.submitting = true;
|
|
const res = await this.props.actions.submit(submission);
|
|
|
|
if (res.error) {
|
|
const errorResponse = res.error;
|
|
const errorMessage = errorResponse.error;
|
|
const hasErrors = this.updateErrors(elements, errorResponse.data?.errors, errorMessage);
|
|
if (!hasErrors) {
|
|
this.handleHide();
|
|
return;
|
|
}
|
|
this.submitting = false;
|
|
return;
|
|
}
|
|
|
|
const callResponse = res.data!;
|
|
switch (callResponse.type) {
|
|
case AppCallResponseTypes.OK:
|
|
case AppCallResponseTypes.NAVIGATE:
|
|
await this.handleHide();
|
|
this.props.actions.handleGotoLocation(callResponse.navigate_to_url!, this.context.intl);
|
|
return;
|
|
case AppCallResponseTypes.FORM:
|
|
this.submitting = false;
|
|
return;
|
|
default:
|
|
this.updateErrors([], undefined, this.context.intl.formatMessage({
|
|
id: 'apps.error.responses.unknown_type',
|
|
defaultMessage: 'App response type not supported. Response type: {type}.',
|
|
}, {
|
|
type: callResponse.type,
|
|
}));
|
|
this.submitting = false;
|
|
}
|
|
}
|
|
|
|
handleSubmit = preventDoubleTap(this.doSubmit);
|
|
|
|
updateErrors = (elements: DialogElement[], fieldErrors?: {[x: string]: string}, formError?: string): boolean => {
|
|
let hasErrors = false;
|
|
const state = {} as State;
|
|
if (formError) {
|
|
hasErrors = true;
|
|
state.formError = formError;
|
|
}
|
|
|
|
if (fieldErrors && Object.keys(fieldErrors).length >= 0) {
|
|
hasErrors = true;
|
|
if (checkIfErrorsMatchElements(fieldErrors as any, elements)) {
|
|
state.fieldErrors = fieldErrors;
|
|
} else if (!state.formError) {
|
|
const field = Object.keys(fieldErrors)[0];
|
|
state.formError = this.context.intl.formatMessage({
|
|
id: 'apps.error.responses.unknown_field_error',
|
|
defaultMessage: 'Received an error for an unkown field. Field name: `{field}`. Error: `{error}`.',
|
|
}, {
|
|
field,
|
|
error: fieldErrors[field],
|
|
});
|
|
}
|
|
}
|
|
|
|
if (hasErrors) {
|
|
this.setState(state);
|
|
if (state.formError && this.scrollView?.current) {
|
|
this.scrollView.current.scrollTo({x: 0, y: 0});
|
|
}
|
|
}
|
|
return hasErrors;
|
|
}
|
|
|
|
performLookup = async (name: string, userInput: string): Promise<AppSelectOption[]> => {
|
|
const intl = this.context.intl;
|
|
const field = this.props.form.fields.find((f) => f.name === name);
|
|
if (!field) {
|
|
return [];
|
|
}
|
|
|
|
const res = await this.props.actions.performLookupCall(field, this.state.values, userInput);
|
|
if (res.error) {
|
|
const errorResponse = res.error;
|
|
const errMsg = errorResponse.error || intl.formatMessage({
|
|
id: 'apps.error.unknown',
|
|
defaultMessage: 'Unknown error.',
|
|
});
|
|
this.setState({
|
|
fieldErrors: {
|
|
...this.state.fieldErrors,
|
|
[field.name]: errMsg,
|
|
},
|
|
});
|
|
return [];
|
|
}
|
|
|
|
const callResp = res.data!;
|
|
switch (callResp.type) {
|
|
case AppCallResponseTypes.OK:
|
|
return callResp.data?.items || [];
|
|
case AppCallResponseTypes.FORM:
|
|
case AppCallResponseTypes.NAVIGATE: {
|
|
const errMsg = intl.formatMessage({
|
|
id: 'apps.error.responses.unexpected_type',
|
|
defaultMessage: 'App response type was not expected. Response type: {type}.',
|
|
}, {
|
|
type: callResp.type,
|
|
},
|
|
);
|
|
this.setState({
|
|
fieldErrors: {
|
|
...this.state.fieldErrors,
|
|
[field.name]: errMsg,
|
|
},
|
|
});
|
|
return [];
|
|
}
|
|
default: {
|
|
const errMsg = intl.formatMessage({
|
|
id: 'apps.error.responses.unknown_type',
|
|
defaultMessage: 'App response type not supported. Response type: {type}.',
|
|
}, {
|
|
type: callResp.type,
|
|
},
|
|
);
|
|
this.setState({
|
|
fieldErrors: {
|
|
...this.state.fieldErrors,
|
|
[field.name]: errMsg,
|
|
},
|
|
});
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
handleHide = async () => {
|
|
await dismissModal();
|
|
}
|
|
|
|
onChange = (name: string, value: any) => {
|
|
const field = this.props.form.fields.find((f) => f.name === name);
|
|
if (!field) {
|
|
return;
|
|
}
|
|
|
|
const values = {...this.state.values, [name]: value};
|
|
|
|
if (field.refresh) {
|
|
this.props.actions.refreshOnSelect(field, values, value).then((res) => {
|
|
if (res.error) {
|
|
const errorResponse = res.error;
|
|
const errorMsg = errorResponse.error;
|
|
const errors = errorResponse.data?.errors;
|
|
const elements = fieldsAsElements(this.props.form.fields);
|
|
this.updateErrors(elements, errors, errorMsg);
|
|
return;
|
|
}
|
|
|
|
const callResponse = res.data!;
|
|
switch (callResponse.type) {
|
|
case AppCallResponseTypes.FORM:
|
|
return;
|
|
case AppCallResponseTypes.OK:
|
|
case AppCallResponseTypes.NAVIGATE:
|
|
this.updateErrors([], undefined, this.context.intl.formatMessage({
|
|
id: 'apps.error.responses.unexpected_type',
|
|
defaultMessage: 'App response type was not expected. Response type: {type}.',
|
|
}, {
|
|
type: callResponse.type,
|
|
}));
|
|
return;
|
|
default:
|
|
this.updateErrors([], undefined, this.context.intl.formatMessage({
|
|
id: 'apps.error.responses.unknown_type',
|
|
defaultMessage: 'App response type not supported. Response type: {type}.',
|
|
}, {
|
|
type: callResponse.type,
|
|
}));
|
|
}
|
|
});
|
|
}
|
|
|
|
this.setState({values});
|
|
};
|
|
|
|
render() {
|
|
const {theme, form} = this.props;
|
|
const {fields, header} = form;
|
|
const {formError, fieldErrors, values} = this.state;
|
|
const style = getStyleFromTheme(theme);
|
|
|
|
const submitButtons = fields && fields.find((f) => f.name === form.submit_buttons);
|
|
|
|
return (
|
|
<SafeAreaView
|
|
testID='interactive_dialog.screen'
|
|
style={style.container}
|
|
>
|
|
<ScrollView
|
|
ref={this.scrollView}
|
|
style={style.scrollView}
|
|
>
|
|
<StatusBar/>
|
|
{formError && (
|
|
<View style={style.errorContainer} >
|
|
<Markdown
|
|
baseTextStyle={style.errorLabel}
|
|
textStyles={getMarkdownTextStyles(theme)}
|
|
blockStyles={getMarkdownBlockStyles(theme)}
|
|
value={formError}
|
|
/>
|
|
</View>
|
|
)}
|
|
{header &&
|
|
<DialogIntroductionText
|
|
value={header}
|
|
theme={theme}
|
|
/>
|
|
}
|
|
{fields && fields.filter((f) => f.name !== form.submit_buttons).map((field) => {
|
|
return (
|
|
<AppsFormField
|
|
field={field}
|
|
key={field.name}
|
|
name={field.name}
|
|
errorText={fieldErrors[field.name]}
|
|
value={values[field.name]}
|
|
performLookup={this.performLookup}
|
|
onChange={this.onChange}
|
|
theme={theme}
|
|
/>
|
|
);
|
|
})}
|
|
<View
|
|
style={{marginHorizontal: 5}}
|
|
>
|
|
{submitButtons?.options?.map((o) => (
|
|
<Button
|
|
key={o.value}
|
|
onPress={() => this.handleSubmit(o.value)}
|
|
containerStyle={GlobalStyles.signupButton}
|
|
>
|
|
<Text style={GlobalStyles.signupButtonText}>{o.label}</Text>
|
|
</Button>
|
|
))}
|
|
</View>
|
|
</ScrollView>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
}
|
|
|
|
function fieldsAsElements(fields?: AppField[]): DialogElement[] {
|
|
return fields?.map((f) => ({
|
|
name: f.name,
|
|
type: f.type,
|
|
subtype: f.subtype,
|
|
optional: !f.is_required,
|
|
})) as DialogElement[];
|
|
}
|
|
|
|
const getStyleFromTheme = makeStyleSheetFromTheme((theme: Theme) => {
|
|
return {
|
|
container: {
|
|
backgroundColor: changeOpacity(theme.centerChannelColor, 0.03),
|
|
},
|
|
errorContainer: {
|
|
marginTop: 15,
|
|
marginLeft: 15,
|
|
fontSize: 14,
|
|
fontWeight: 'bold',
|
|
},
|
|
scrollView: {
|
|
marginBottom: 20,
|
|
marginTop: 10,
|
|
},
|
|
button: {
|
|
alignSelf: 'stretch',
|
|
backgroundColor: theme.sidebarHeaderBg,
|
|
borderRadius: 3,
|
|
padding: 15,
|
|
},
|
|
errorLabel: {
|
|
fontSize: 12,
|
|
textAlign: 'left',
|
|
color: (theme.errorTextColor || '#DA4A4A'),
|
|
},
|
|
};
|
|
});
|