sentryAppExternalForm.tsx 14 KB

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