sentryAppExternalForm.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  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/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. uri: string;
  27. description?: 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(value => value.name === field.name);
  162. if (reset) {
  163. defaultValue = reset.value;
  164. }
  165. return defaultValue;
  166. };
  167. debouncedOptionLoad = debounce(
  168. // debounce is used to prevent making a request for every input change and
  169. // instead makes the requests every 200ms
  170. async (field: FieldFromSchema, input, resolve) => {
  171. const choices = await this.makeExternalRequest(field, input);
  172. const options = choices.map(([value, label]) => ({value, label}));
  173. const optionsByField = new Map(this.state.optionsByField);
  174. optionsByField.set(field.name, options);
  175. this.setState({
  176. optionsByField,
  177. });
  178. return resolve(options);
  179. },
  180. 200,
  181. {trailing: true}
  182. );
  183. makeExternalRequest = async (field: FieldFromSchema, input: FieldValue) => {
  184. const {extraRequestBody = {}, sentryAppInstallationUuid} = this.props;
  185. const query: {[key: string]: any} = {
  186. ...extraRequestBody,
  187. uri: field.uri,
  188. query: input,
  189. };
  190. if (field.depends_on) {
  191. const dependentData = field.depends_on.reduce((accum, dependentField: string) => {
  192. accum[dependentField] = this.model.getValue(dependentField);
  193. return accum;
  194. }, {});
  195. // stringify the data
  196. query.dependentData = JSON.stringify(dependentData);
  197. }
  198. const {choices} = await this.props.api.requestPromise(
  199. `/sentry-app-installations/${sentryAppInstallationUuid}/external-requests/`,
  200. {query}
  201. );
  202. return choices || [];
  203. };
  204. /**
  205. * This function determines which fields need to be reset and new options fetched
  206. * based on the dependencies defined with the depends_on attribute.
  207. * This is done because the autoload flag causes fields to load at different times
  208. * if you have multiple dependent fields while this solution updates state at once.
  209. */
  210. handleFieldChange = async (id: string) => {
  211. const config = this.state;
  212. let requiredFields = config.required_fields || [];
  213. let optionalFields = config.optional_fields || [];
  214. const fieldList: FieldFromSchema[] = requiredFields.concat(optionalFields);
  215. // could have multiple impacted fields
  216. const impactedFields = fieldList.filter(({depends_on}) => {
  217. if (!depends_on) {
  218. return false;
  219. }
  220. // must be dependent on the field we just set
  221. return depends_on.includes(id);
  222. });
  223. // load all options in parallel
  224. const choiceArray = await Promise.all(
  225. impactedFields.map(field => {
  226. // reset all impacted fields first
  227. this.model.setValue(field.name || '', '', {quiet: true});
  228. return this.makeExternalRequest(field, '');
  229. })
  230. );
  231. this.setState(state => {
  232. // pull the field lists from latest state
  233. requiredFields = state.required_fields || [];
  234. optionalFields = state.optional_fields || [];
  235. // iterate through all the impacted fields and get new values
  236. impactedFields.forEach((impactedField, i) => {
  237. const choices = choiceArray[i];
  238. const requiredIndex = requiredFields.indexOf(impactedField);
  239. const optionalIndex = optionalFields.indexOf(impactedField);
  240. const updatedField = {...impactedField, choices};
  241. // immutably update the lists with the updated field depending where we got it from
  242. if (requiredIndex > -1) {
  243. requiredFields = replaceAtArrayIndex(
  244. requiredFields,
  245. requiredIndex,
  246. updatedField
  247. );
  248. } else if (optionalIndex > -1) {
  249. optionalFields = replaceAtArrayIndex(
  250. optionalFields,
  251. optionalIndex,
  252. updatedField
  253. );
  254. }
  255. });
  256. return {
  257. required_fields: requiredFields,
  258. optional_fields: optionalFields,
  259. };
  260. });
  261. };
  262. createPreserveOptionFunction = (name: string) => (option, _event) => {
  263. this.setState({
  264. selectedOptions: {
  265. ...this.state.selectedOptions,
  266. [name]: option,
  267. },
  268. });
  269. };
  270. renderField = (field: FieldFromSchema, required: boolean) => {
  271. // This function converts the field we get from the backend into
  272. // the field we need to pass down
  273. let fieldToPass: Field = {
  274. ...field,
  275. inline: false,
  276. stacked: true,
  277. flexibleControlStateSize: true,
  278. required,
  279. };
  280. if (field?.uri && field?.async) {
  281. fieldToPass.type = 'select_async';
  282. }
  283. if (['select', 'select_async'].includes(fieldToPass.type || '')) {
  284. // find the options from state to pass down
  285. const defaultOptions = this.getDefaultOptions(field);
  286. const options = this.state.optionsByField.get(field.name) || defaultOptions;
  287. fieldToPass = {
  288. ...fieldToPass,
  289. options,
  290. defaultOptions,
  291. defaultValue: this.getDefaultFieldValue(field),
  292. // filter by what the user is typing
  293. filterOption: createFilter({}),
  294. allowClear: !required,
  295. placeholder: 'Type to search',
  296. } as Field;
  297. if (field.depends_on) {
  298. // check if this is dependent on other fields which haven't been set yet
  299. const shouldDisable = field.depends_on.some(
  300. dependentField => !hasValue(this.model.getValue(dependentField))
  301. );
  302. if (shouldDisable) {
  303. fieldToPass = {...fieldToPass, disabled: true};
  304. }
  305. }
  306. }
  307. if (['text', 'textarea'].includes(fieldToPass.type || '')) {
  308. fieldToPass = {
  309. ...fieldToPass,
  310. defaultValue: this.getDefaultFieldValue(field),
  311. };
  312. }
  313. // if we have a uri, we need to set extra parameters
  314. const extraProps = field.uri
  315. ? {
  316. loadOptions: (input: string) => this.getOptions(field, input),
  317. async: field?.async ?? true,
  318. cache: false,
  319. onSelectResetsInput: false,
  320. onCloseResetsInput: false,
  321. onBlurResetsInput: false,
  322. autoload: false,
  323. onChangeOption: this.createPreserveOptionFunction(field.name),
  324. }
  325. : {};
  326. return (
  327. <FieldFromConfig
  328. key={field.name}
  329. field={fieldToPass}
  330. data-test-id={field.name}
  331. {...extraProps}
  332. />
  333. );
  334. };
  335. handleAlertRuleSubmit = (formData, onSubmitSuccess) => {
  336. const {sentryAppInstallationUuid} = this.props;
  337. if (this.model.validateForm()) {
  338. onSubmitSuccess({
  339. // The form data must be nested in 'settings' to ensure they don't overlap with any other field names.
  340. settings: Object.entries(formData).map(([name, value]) => {
  341. const savedSetting: SentryAppSetting = {name, value};
  342. const stateOption = this.state.selectedOptions[name];
  343. // If the field is a SelectAsync, we need to preserve the label since the next time it's rendered,
  344. // we can't be sure the options will contain this selection
  345. if (stateOption?.value === value) {
  346. savedSetting.label = `${stateOption?.label}`;
  347. }
  348. return savedSetting;
  349. }),
  350. sentryAppInstallationUuid,
  351. // Used on the backend to explicitly associate with a different rule than those without a custom form.
  352. hasSchemaFormConfig: true,
  353. });
  354. }
  355. };
  356. render() {
  357. const {sentryAppInstallationUuid, action, element, onSubmitSuccess} = this.props;
  358. const requiredFields = this.state.required_fields || [];
  359. const optionalFields = this.state.optional_fields || [];
  360. if (!sentryAppInstallationUuid) {
  361. return '';
  362. }
  363. return (
  364. <Form
  365. key={action}
  366. apiEndpoint={`/sentry-app-installations/${sentryAppInstallationUuid}/external-issue-actions/`}
  367. apiMethod="POST"
  368. // Without defining onSubmit, the Form will send an `apiMethod` request to the above `apiEndpoint`
  369. onSubmit={
  370. element === 'alert-rule-action' ? this.handleAlertRuleSubmit : undefined
  371. }
  372. onSubmitSuccess={(...params) => {
  373. onSubmitSuccess(...params);
  374. }}
  375. onSubmitError={this.onSubmitError}
  376. onFieldChange={this.handleFieldChange}
  377. preventFormResetOnUnmount
  378. model={this.model}
  379. >
  380. {requiredFields.map((field: FieldFromSchema) => {
  381. return this.renderField(field, true);
  382. })}
  383. {optionalFields.map((field: FieldFromSchema) => {
  384. return this.renderField(field, false);
  385. })}
  386. </Form>
  387. );
  388. }
  389. }
  390. export default withApi(SentryAppExternalForm);