import {Component} from 'react'; import {createFilter} from 'react-select'; import debounce from 'lodash/debounce'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import type {Client} from 'sentry/api'; import type {GeneralSelectValue} from 'sentry/components/forms/controls/selectControl'; import FieldFromConfig from 'sentry/components/forms/fieldFromConfig'; import Form from 'sentry/components/forms/form'; import FormModel from 'sentry/components/forms/model'; import type {Field, FieldValue} from 'sentry/components/forms/types'; import {t} from 'sentry/locale'; import {replaceAtArrayIndex} from 'sentry/utils/replaceAtArrayIndex'; import withApi from 'sentry/utils/withApi'; // 0 is a valid choice but empty string, undefined, and null are not const hasValue = value => !!value || value === 0; // See docs: https://docs.sentry.io/product/integrations/integration-platform/ui-components/formfield/ export type FieldFromSchema = Omit & { type: 'select' | 'textarea' | 'text'; async?: boolean; choices?: Array<[any, string]>; default?: 'issue.title' | 'issue.description'; depends_on?: string[]; uri?: string; }; export type SchemaFormConfig = { uri: string; description?: string; optional_fields?: FieldFromSchema[]; required_fields?: FieldFromSchema[]; }; type SentryAppSetting = { name: string; value: any; label?: string; }; // only need required_fields and optional_fields type State = Omit & { optionsByField: Map>; selectedOptions: {[name: string]: GeneralSelectValue}; }; type Props = { action: 'create' | 'link'; api: Client; appName: string; config: SchemaFormConfig; element: 'issue-link' | 'alert-rule-action'; onSubmitSuccess: Function; sentryAppInstallationUuid: string; /** * Additional form data to submit with the request */ extraFields?: {[key: string]: any}; /** * Additional body parameters to submit with the request */ extraRequestBody?: {[key: string]: any}; /** * Function to provide fields with pre-written data if a default is specified */ getFieldDefault?: (field: FieldFromSchema) => string; /** * Object containing reset values for fields if previously entered, in case this form is unmounted */ resetValues?: { [key: string]: any; settings?: SentryAppSetting[]; }; }; /** * This component is the result of a refactor of sentryAppExternalIssueForm.tsx. * Most of it contains a direct copy of the code from that original file (comments included) * to allow for an abstract way of turning Sentry App Schema -> Form UI, rather than being * specific to Issue Linking. * * See (#28465) for more details. */ export class SentryAppExternalForm extends Component { state: State = {optionsByField: new Map(), selectedOptions: {}}; componentDidMount() { this.resetStateFromProps(); } componentDidUpdate(prevProps: Props) { if (prevProps.action !== this.props.action) { this.model.reset(); this.resetStateFromProps(); } } model = new FormModel(); // reset the state when we mount or the action changes resetStateFromProps() { const {config, action, extraFields, element} = this.props; this.setState({ required_fields: config.required_fields, optional_fields: config.optional_fields, }); this.model.reset(); // For alert-rule-actions, the forms are entirely custom, extra fields are // passed in on submission, not as part of the form. See handleAlertRuleSubmit(). if (element === 'alert-rule-action') { const defaultResetValues = this.props.resetValues?.settings || []; const initialData = defaultResetValues.reduce((acc, curr) => { acc[curr.name] = curr.value; return acc; }, {}); this.model.setInitialData({...initialData}); } else { this.model.setInitialData({ ...extraFields, // we need to pass these fields in the API so just set them as values so we don't need hidden form fields action, uri: config.uri, }); } } onSubmitError = () => { const {action, appName} = this.props; addErrorMessage(t('Unable to %s %s %s.', action, appName, this.getElementText())); }; getOptions = (field: FieldFromSchema, input: string) => new Promise(resolve => { this.debouncedOptionLoad(field, input, resolve); }); getElementText = () => { const {element} = this.props; switch (element) { case 'issue-link': return 'issue'; case 'alert-rule-action': return 'alert'; default: return 'connection'; } }; getDefaultOptions = (field: FieldFromSchema) => { const savedOption = (this.props.resetValues?.settings || []).find( value => value.name === field.name ); const currentOptions = (field.choices || []).map(([value, label]) => ({ value, label, })); const shouldAddSavedOption = // We only render saved options if they have preserved the label, otherwise it appears unselcted. // The next time the user saves, the label should be preserved. savedOption?.value && savedOption?.label && // The option isn't in the current options already !currentOptions.some(option => option.value === savedOption?.value); return shouldAddSavedOption ? [{value: savedOption.value, label: savedOption.label}, ...currentOptions] : currentOptions; }; getDefaultFieldValue = (field: FieldFromSchema) => { // Interpret the default if a getFieldDefault function is provided. const {resetValues, getFieldDefault} = this.props; let defaultValue = field?.defaultValue; // Override this default if a reset value is provided if (field.default && getFieldDefault) { defaultValue = getFieldDefault(field); } const reset = resetValues?.settings?.find(value => value.name === field.name); if (reset) { defaultValue = reset.value; } return defaultValue; }; debouncedOptionLoad = debounce( // debounce is used to prevent making a request for every input change and // instead makes the requests every 200ms async (field: FieldFromSchema, input, resolve) => { const choices = await this.makeExternalRequest(field, input); const options = choices.map(([value, label]) => ({value, label})); const optionsByField = new Map(this.state.optionsByField); optionsByField.set(field.name, options); this.setState({ optionsByField, }); return resolve(options); }, 200, {trailing: true} ); makeExternalRequest = async (field: FieldFromSchema, input: FieldValue) => { const {extraRequestBody = {}, sentryAppInstallationUuid} = this.props; const query: {[key: string]: any} = { ...extraRequestBody, uri: field.uri, query: input, }; if (field.depends_on) { const dependentData = field.depends_on.reduce((accum, dependentField: string) => { accum[dependentField] = this.model.getValue(dependentField); return accum; }, {}); // stringify the data query.dependentData = JSON.stringify(dependentData); } const {choices} = await this.props.api.requestPromise( `/sentry-app-installations/${sentryAppInstallationUuid}/external-requests/`, {query} ); return choices || []; }; /** * This function determines which fields need to be reset and new options fetched * based on the dependencies defined with the depends_on attribute. * This is done because the autoload flag causes fields to load at different times * if you have multiple dependent fields while this solution updates state at once. */ handleFieldChange = async (id: string) => { const config = this.state; let requiredFields = config.required_fields || []; let optionalFields = config.optional_fields || []; const fieldList: FieldFromSchema[] = requiredFields.concat(optionalFields); // could have multiple impacted fields const impactedFields = fieldList.filter(({depends_on}) => { if (!depends_on) { return false; } // must be dependent on the field we just set return depends_on.includes(id); }); // load all options in parallel const choiceArray = await Promise.all( impactedFields.map(field => { // reset all impacted fields first this.model.setValue(field.name || '', '', {quiet: true}); return this.makeExternalRequest(field, ''); }) ); this.setState(state => { // pull the field lists from latest state requiredFields = state.required_fields || []; optionalFields = state.optional_fields || []; // iterate through all the impacted fields and get new values impactedFields.forEach((impactedField, i) => { const choices = choiceArray[i]; const requiredIndex = requiredFields.indexOf(impactedField); const optionalIndex = optionalFields.indexOf(impactedField); const updatedField = {...impactedField, choices}; // immutably update the lists with the updated field depending where we got it from if (requiredIndex > -1) { requiredFields = replaceAtArrayIndex( requiredFields, requiredIndex, updatedField ); } else if (optionalIndex > -1) { optionalFields = replaceAtArrayIndex( optionalFields, optionalIndex, updatedField ); } }); return { required_fields: requiredFields, optional_fields: optionalFields, }; }); }; createPreserveOptionFunction = (name: string) => (option, _event) => { this.setState({ selectedOptions: { ...this.state.selectedOptions, [name]: option, }, }); }; renderField = (field: FieldFromSchema, required: boolean) => { // This function converts the field we get from the backend into // the field we need to pass down let fieldToPass: Field = { ...field, inline: false, stacked: true, flexibleControlStateSize: true, required, }; if (field?.uri && field?.async) { fieldToPass.type = 'select_async'; } if (['select', 'select_async'].includes(fieldToPass.type || '')) { // find the options from state to pass down const defaultOptions = this.getDefaultOptions(field); const options = this.state.optionsByField.get(field.name) || defaultOptions; fieldToPass = { ...fieldToPass, options, defaultOptions, defaultValue: this.getDefaultFieldValue(field), // filter by what the user is typing filterOption: createFilter({}), allowClear: !required, placeholder: 'Type to search', } as Field; if (field.depends_on) { // check if this is dependent on other fields which haven't been set yet const shouldDisable = field.depends_on.some( dependentField => !hasValue(this.model.getValue(dependentField)) ); if (shouldDisable) { fieldToPass = {...fieldToPass, disabled: true}; } } } if (['text', 'textarea'].includes(fieldToPass.type || '')) { fieldToPass = { ...fieldToPass, defaultValue: this.getDefaultFieldValue(field), }; } // if we have a uri, we need to set extra parameters const extraProps = field.uri ? { loadOptions: (input: string) => this.getOptions(field, input), async: field?.async ?? true, cache: false, onSelectResetsInput: false, onCloseResetsInput: false, onBlurResetsInput: false, autoload: false, onChangeOption: this.createPreserveOptionFunction(field.name), } : {}; return ( ); }; handleAlertRuleSubmit = (formData, onSubmitSuccess) => { const {sentryAppInstallationUuid} = this.props; if (this.model.validateForm()) { onSubmitSuccess({ // The form data must be nested in 'settings' to ensure they don't overlap with any other field names. settings: Object.entries(formData).map(([name, value]) => { const savedSetting: SentryAppSetting = {name, value}; const stateOption = this.state.selectedOptions[name]; // If the field is a SelectAsync, we need to preserve the label since the next time it's rendered, // we can't be sure the options will contain this selection if (stateOption?.value === value) { savedSetting.label = `${stateOption?.label}`; } return savedSetting; }), sentryAppInstallationUuid, // Used on the backend to explicitly associate with a different rule than those without a custom form. hasSchemaFormConfig: true, }); } }; render() { const {sentryAppInstallationUuid, action, element, onSubmitSuccess} = this.props; const requiredFields = this.state.required_fields || []; const optionalFields = this.state.optional_fields || []; if (!sentryAppInstallationUuid) { return ''; } return (
{ onSubmitSuccess(...params); }} onSubmitError={this.onSubmitError} onFieldChange={this.handleFieldChange} preventFormResetOnUnmount model={this.model} > {requiredFields.map((field: FieldFromSchema) => { return this.renderField(field, true); })} {optionalFields.map((field: FieldFromSchema) => { return this.renderField(field, false); })}
); } } export default withApi(SentryAppExternalForm);