123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440 |
- import {Component} from 'react';
- import {createFilter} from 'react-select';
- import debounce from 'lodash/debounce';
- import {addErrorMessage} from 'sentry/actionCreators/indicator';
- import {Client} from 'sentry/api';
- import {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 {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<Field, 'choices' | 'type'> & {
- type: 'select' | 'textarea' | 'text';
- async?: boolean;
- choices?: Array<[any, string]>;
- default?: 'issue.title' | 'issue.description';
- depends_on?: string[];
- uri?: string;
- };
- export type SchemaFormConfig = {
- description: string | null;
- uri: 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<SchemaFormConfig, 'uri' | 'description'> & {
- optionsByField: Map<string, Array<{label: string; value: any}>>;
- 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<Props, State> {
- 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 (
- <FieldFromConfig
- key={field.name}
- field={fieldToPass}
- data-test-id={field.name}
- {...extraProps}
- />
- );
- };
- 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 (
- <Form
- key={action}
- apiEndpoint={`/sentry-app-installations/${sentryAppInstallationUuid}/external-issue-actions/`}
- apiMethod="POST"
- // Without defining onSubmit, the Form will send an `apiMethod` request to the above `apiEndpoint`
- onSubmit={
- element === 'alert-rule-action' ? this.handleAlertRuleSubmit : undefined
- }
- onSubmitSuccess={(...params) => {
- 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);
- })}
- </Form>
- );
- }
- }
- export default withApi(SentryAppExternalForm);
|