sentryAppExternalForm.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. import {Component} from 'react';
  2. import {createFilter} from 'react-select';
  3. import debounce from 'lodash/debounce';
  4. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  5. import type {Client} from 'sentry/api';
  6. import type {GeneralSelectValue} from 'sentry/components/forms/controls/selectControl';
  7. import FieldFromConfig from 'sentry/components/forms/fieldFromConfig';
  8. import Form from 'sentry/components/forms/form';
  9. import FormModel from 'sentry/components/forms/model';
  10. import type {Field, FieldValue} from 'sentry/components/forms/types';
  11. import {t} from 'sentry/locale';
  12. import replaceAtArrayIndex from 'sentry/utils/array/replaceAtArrayIndex';
  13. import withApi from 'sentry/utils/withApi';
  14. // 0 is a valid choice but empty string, undefined, and null are not
  15. const hasValue = value => !!value || value === 0;
  16. // See docs: https://docs.sentry.io/product/integrations/integration-platform/ui-components/formfield/
  17. export type FieldFromSchema = Omit<Field, 'choices' | 'type'> & {
  18. type: 'select' | 'textarea' | 'text';
  19. async?: boolean;
  20. choices?: Array<[any, string]>;
  21. default?: 'issue.title' | 'issue.description';
  22. depends_on?: string[];
  23. skip_load_on_open?: boolean;
  24. uri?: string;
  25. };
  26. export type SchemaFormConfig = {
  27. uri: string;
  28. description?: string;
  29. optional_fields?: FieldFromSchema[];
  30. required_fields?: FieldFromSchema[];
  31. };
  32. type SentryAppSetting = {
  33. name: string;
  34. value: any;
  35. label?: string;
  36. };
  37. // only need required_fields and optional_fields
  38. type State = Omit<SchemaFormConfig, 'uri' | 'description'> & {
  39. optionsByField: Map<string, Array<{label: string; value: any}>>;
  40. selectedOptions: {[name: string]: GeneralSelectValue};
  41. };
  42. type Props = {
  43. action: 'create' | 'link';
  44. api: Client;
  45. appName: string;
  46. config: SchemaFormConfig;
  47. element: 'issue-link' | 'alert-rule-action';
  48. onSubmitSuccess: Function;
  49. sentryAppInstallationUuid: string;
  50. /**
  51. * Additional form data to submit with the request
  52. */
  53. extraFields?: {[key: string]: any};
  54. /**
  55. * Additional body parameters to submit with the request
  56. */
  57. extraRequestBody?: {[key: string]: any};
  58. /**
  59. * Function to provide fields with pre-written data if a default is specified
  60. */
  61. getFieldDefault?: (field: FieldFromSchema) => string;
  62. /**
  63. * Object containing reset values for fields if previously entered, in case this form is unmounted
  64. */
  65. resetValues?: {
  66. [key: string]: any;
  67. settings?: SentryAppSetting[];
  68. };
  69. };
  70. /**
  71. * This component is the result of a refactor of sentryAppExternalIssueForm.tsx.
  72. * Most of it contains a direct copy of the code from that original file (comments included)
  73. * to allow for an abstract way of turning Sentry App Schema -> Form UI, rather than being
  74. * specific to Issue Linking.
  75. *
  76. * See (#28465) for more details.
  77. */
  78. export class SentryAppExternalForm extends Component<Props, State> {
  79. state: State = {optionsByField: new Map(), selectedOptions: {}};
  80. componentDidMount() {
  81. this.resetStateFromProps();
  82. }
  83. componentDidUpdate(prevProps: Props) {
  84. if (prevProps.action !== this.props.action) {
  85. this.model.reset();
  86. this.resetStateFromProps();
  87. }
  88. }
  89. model = new FormModel();
  90. // reset the state when we mount or the action changes
  91. resetStateFromProps() {
  92. const {config, action, extraFields, element} = this.props;
  93. this.setState({
  94. required_fields: config.required_fields,
  95. optional_fields: config.optional_fields,
  96. });
  97. this.model.reset();
  98. // For alert-rule-actions, the forms are entirely custom, extra fields are
  99. // passed in on submission, not as part of the form. See handleAlertRuleSubmit().
  100. if (element === 'alert-rule-action') {
  101. const defaultResetValues = this.props.resetValues?.settings || [];
  102. const initialData = defaultResetValues.reduce((acc, curr) => {
  103. acc[curr.name] = curr.value;
  104. return acc;
  105. }, {});
  106. this.model.setInitialData({...initialData});
  107. } else {
  108. this.model.setInitialData({
  109. ...extraFields,
  110. // we need to pass these fields in the API so just set them as values so we don't need hidden form fields
  111. action,
  112. uri: config.uri,
  113. });
  114. }
  115. // let the state update before we try and load the dependent options
  116. setTimeout(() => {
  117. this.tryAndLoadDependentOptions();
  118. }, 0);
  119. }
  120. tryAndLoadDependentOptions = () => {
  121. const {required_fields, optional_fields} = this.state;
  122. // first find every field where we don't load the values on open
  123. const fieldsToLoad = [...(required_fields || []), ...(optional_fields || [])].filter(
  124. field => field.skip_load_on_open
  125. );
  126. fieldsToLoad.forEach(field => {
  127. if (field.depends_on && field.depends_on.length > 0) {
  128. // check that we can load this field
  129. const isReadyToLoad = field.depends_on.every(dependentField => {
  130. return !!this.model.getValue(dependentField);
  131. });
  132. // if ready to load, trigger a field change to trigger the api request to load options
  133. if (isReadyToLoad) {
  134. this.handleFieldChange(field.depends_on[0]);
  135. }
  136. }
  137. });
  138. };
  139. onSubmitError = () => {
  140. const {action, appName} = this.props;
  141. addErrorMessage(t('Unable to %s %s %s.', action, appName, this.getElementText()));
  142. };
  143. getOptions = (field: FieldFromSchema, input: string) =>
  144. new Promise(resolve => {
  145. this.debouncedOptionLoad(field, input, resolve);
  146. });
  147. getElementText = () => {
  148. const {element} = this.props;
  149. switch (element) {
  150. case 'issue-link':
  151. return 'issue';
  152. case 'alert-rule-action':
  153. return 'alert';
  154. default:
  155. return 'connection';
  156. }
  157. };
  158. getDefaultOptions = (field: FieldFromSchema) => {
  159. const savedOption = (this.props.resetValues?.settings || []).find(
  160. value => value.name === field.name
  161. );
  162. const currentOptions = (field.choices || []).map(([value, label]) => ({
  163. value,
  164. label,
  165. }));
  166. const shouldAddSavedOption =
  167. // We only render saved options if they have preserved the label, otherwise it appears unselcted.
  168. // The next time the user saves, the label should be preserved.
  169. savedOption?.value &&
  170. savedOption?.label &&
  171. // The option isn't in the current options already
  172. !currentOptions.some(option => option.value === savedOption?.value);
  173. return shouldAddSavedOption
  174. ? [{value: savedOption.value, label: savedOption.label}, ...currentOptions]
  175. : currentOptions;
  176. };
  177. getDefaultFieldValue = (field: FieldFromSchema) => {
  178. // Interpret the default if a getFieldDefault function is provided.
  179. const {resetValues, getFieldDefault} = this.props;
  180. let defaultValue = field?.defaultValue;
  181. // Override this default if a reset value is provided
  182. if (field.default && getFieldDefault) {
  183. defaultValue = getFieldDefault(field);
  184. }
  185. const reset = resetValues?.settings?.find(value => value.name === field.name);
  186. if (reset) {
  187. defaultValue = reset.value;
  188. }
  189. return defaultValue;
  190. };
  191. debouncedOptionLoad = debounce(
  192. // debounce is used to prevent making a request for every input change and
  193. // instead makes the requests every 200ms
  194. async (field: FieldFromSchema, input, resolve) => {
  195. const choices = await this.makeExternalRequest(field, input);
  196. const options = choices.map(([value, label]) => ({value, label}));
  197. const optionsByField = new Map(this.state.optionsByField);
  198. optionsByField.set(field.name, options);
  199. this.setState({
  200. optionsByField,
  201. });
  202. return resolve(options);
  203. },
  204. 200,
  205. {trailing: true}
  206. );
  207. makeExternalRequest = async (field: FieldFromSchema, input: FieldValue) => {
  208. const {extraRequestBody = {}, sentryAppInstallationUuid} = this.props;
  209. const query: {[key: string]: any} = {
  210. ...extraRequestBody,
  211. uri: field.uri,
  212. query: input,
  213. };
  214. if (field.depends_on) {
  215. const dependentData = field.depends_on.reduce((accum, dependentField: string) => {
  216. accum[dependentField] = this.model.getValue(dependentField);
  217. return accum;
  218. }, {});
  219. // stringify the data
  220. query.dependentData = JSON.stringify(dependentData);
  221. }
  222. const {choices} = await this.props.api.requestPromise(
  223. `/sentry-app-installations/${sentryAppInstallationUuid}/external-requests/`,
  224. {query}
  225. );
  226. return choices || [];
  227. };
  228. /**
  229. * This function determines which fields need to be reset and new options fetched
  230. * based on the dependencies defined with the depends_on attribute.
  231. * This is done because the autoload flag causes fields to load at different times
  232. * if you have multiple dependent fields while this solution updates state at once.
  233. */
  234. handleFieldChange = async (id: string) => {
  235. const config = this.state;
  236. let requiredFields = config.required_fields || [];
  237. let optionalFields = config.optional_fields || [];
  238. const fieldList: FieldFromSchema[] = requiredFields.concat(optionalFields);
  239. // could have multiple impacted fields
  240. const impactedFields = fieldList.filter(({depends_on}) => {
  241. if (!depends_on) {
  242. return false;
  243. }
  244. // must be dependent on the field we just set
  245. return depends_on.includes(id);
  246. });
  247. // load all options in parallel
  248. const choiceArray = await Promise.all(
  249. impactedFields.map(field => {
  250. // reset all impacted fields first
  251. this.model.setValue(field.name || '', '', {quiet: true});
  252. return this.makeExternalRequest(field, '');
  253. })
  254. );
  255. this.setState(state => {
  256. // pull the field lists from latest state
  257. requiredFields = state.required_fields || [];
  258. optionalFields = state.optional_fields || [];
  259. // iterate through all the impacted fields and get new values
  260. impactedFields.forEach((impactedField, i) => {
  261. const choices = choiceArray[i];
  262. const requiredIndex = requiredFields.indexOf(impactedField);
  263. const optionalIndex = optionalFields.indexOf(impactedField);
  264. const updatedField = {...impactedField, choices};
  265. // immutably update the lists with the updated field depending where we got it from
  266. if (requiredIndex > -1) {
  267. requiredFields = replaceAtArrayIndex(
  268. requiredFields,
  269. requiredIndex,
  270. updatedField
  271. );
  272. } else if (optionalIndex > -1) {
  273. optionalFields = replaceAtArrayIndex(
  274. optionalFields,
  275. optionalIndex,
  276. updatedField
  277. );
  278. }
  279. });
  280. return {
  281. required_fields: requiredFields,
  282. optional_fields: optionalFields,
  283. };
  284. });
  285. };
  286. createPreserveOptionFunction = (name: string) => (option, _event) => {
  287. this.setState({
  288. selectedOptions: {
  289. ...this.state.selectedOptions,
  290. [name]: option,
  291. },
  292. });
  293. };
  294. renderField = (field: FieldFromSchema, required: boolean) => {
  295. // This function converts the field we get from the backend into
  296. // the field we need to pass down
  297. let fieldToPass: Field = {
  298. ...field,
  299. inline: false,
  300. stacked: true,
  301. flexibleControlStateSize: true,
  302. required,
  303. };
  304. if (field?.uri && field?.async) {
  305. fieldToPass.type = 'select_async';
  306. }
  307. if (['select', 'select_async'].includes(fieldToPass.type || '')) {
  308. // find the options from state to pass down
  309. const defaultOptions = this.getDefaultOptions(field);
  310. const options = this.state.optionsByField.get(field.name) || defaultOptions;
  311. fieldToPass = {
  312. ...fieldToPass,
  313. options,
  314. defaultOptions,
  315. defaultValue: this.getDefaultFieldValue(field),
  316. // filter by what the user is typing
  317. filterOption: createFilter({}),
  318. allowClear: !required,
  319. placeholder: 'Type to search',
  320. } as Field;
  321. if (field.depends_on) {
  322. // check if this is dependent on other fields which haven't been set yet
  323. const shouldDisable = field.depends_on.some(
  324. dependentField => !hasValue(this.model.getValue(dependentField))
  325. );
  326. if (shouldDisable) {
  327. fieldToPass = {...fieldToPass, disabled: true};
  328. }
  329. }
  330. }
  331. if (['text', 'textarea'].includes(fieldToPass.type || '')) {
  332. fieldToPass = {
  333. ...fieldToPass,
  334. defaultValue: this.getDefaultFieldValue(field),
  335. };
  336. }
  337. // if we have a uri, we need to set extra parameters
  338. const extraProps = field.uri
  339. ? {
  340. loadOptions: (input: string) => this.getOptions(field, input),
  341. async: field?.async ?? true,
  342. cache: false,
  343. onSelectResetsInput: false,
  344. onCloseResetsInput: false,
  345. onBlurResetsInput: false,
  346. autoload: false,
  347. onChangeOption: this.createPreserveOptionFunction(field.name),
  348. }
  349. : {};
  350. return (
  351. <FieldFromConfig
  352. key={field.name}
  353. field={fieldToPass}
  354. data-test-id={field.name}
  355. {...extraProps}
  356. />
  357. );
  358. };
  359. handleAlertRuleSubmit = (formData, onSubmitSuccess) => {
  360. const {sentryAppInstallationUuid} = this.props;
  361. if (this.model.validateForm()) {
  362. onSubmitSuccess({
  363. // The form data must be nested in 'settings' to ensure they don't overlap with any other field names.
  364. settings: Object.entries(formData).map(([name, value]) => {
  365. const savedSetting: SentryAppSetting = {name, value};
  366. const stateOption = this.state.selectedOptions[name];
  367. // If the field is a SelectAsync, we need to preserve the label since the next time it's rendered,
  368. // we can't be sure the options will contain this selection
  369. if (stateOption?.value === value) {
  370. savedSetting.label = `${stateOption?.label}`;
  371. }
  372. return savedSetting;
  373. }),
  374. sentryAppInstallationUuid,
  375. // Used on the backend to explicitly associate with a different rule than those without a custom form.
  376. hasSchemaFormConfig: true,
  377. });
  378. }
  379. };
  380. render() {
  381. const {sentryAppInstallationUuid, action, element, onSubmitSuccess} = this.props;
  382. const requiredFields = this.state.required_fields || [];
  383. const optionalFields = this.state.optional_fields || [];
  384. if (!sentryAppInstallationUuid) {
  385. return '';
  386. }
  387. return (
  388. <Form
  389. key={action}
  390. apiEndpoint={`/sentry-app-installations/${sentryAppInstallationUuid}/external-issue-actions/`}
  391. apiMethod="POST"
  392. // Without defining onSubmit, the Form will send an `apiMethod` request to the above `apiEndpoint`
  393. onSubmit={
  394. element === 'alert-rule-action' ? this.handleAlertRuleSubmit : undefined
  395. }
  396. onSubmitSuccess={(...params) => {
  397. onSubmitSuccess(...params);
  398. }}
  399. onSubmitError={this.onSubmitError}
  400. onFieldChange={this.handleFieldChange}
  401. preventFormResetOnUnmount
  402. model={this.model}
  403. >
  404. {requiredFields.map((field: FieldFromSchema) => {
  405. return this.renderField(field, true);
  406. })}
  407. {optionalFields.map((field: FieldFromSchema) => {
  408. return this.renderField(field, false);
  409. })}
  410. </Form>
  411. );
  412. }
  413. }
  414. export default withApi(SentryAppExternalForm);