sentryAppExternalForm.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436
  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. // For alert-rule-actions, the forms are entirely custom, extra fields are
  97. // passed in on submission, not as part of the form. See handleAlertRuleSubmit().
  98. if (element === 'alert-rule-action') {
  99. const defaultResetValues = (this.props.resetValues || {}).settings || [];
  100. const initialData = defaultResetValues.reduce((acc, curr) => {
  101. acc[curr.name] = curr.value;
  102. return acc;
  103. }, {});
  104. this.model.setInitialData({...initialData});
  105. } else {
  106. this.model.setInitialData({
  107. ...extraFields,
  108. // we need to pass these fields in the API so just set them as values so we don't need hidden form fields
  109. action,
  110. uri: config.uri,
  111. });
  112. }
  113. }
  114. onSubmitError = () => {
  115. const {action, appName} = this.props;
  116. addErrorMessage(t('Unable to %s %s %s.', action, appName, this.getElementText()));
  117. };
  118. getOptions = (field: FieldFromSchema, input: string) =>
  119. new Promise(resolve => {
  120. this.debouncedOptionLoad(field, input, resolve);
  121. });
  122. getElementText = () => {
  123. const {element} = this.props;
  124. switch (element) {
  125. case 'issue-link':
  126. return 'issue';
  127. case 'alert-rule-action':
  128. return 'alert';
  129. default:
  130. return 'connection';
  131. }
  132. };
  133. getDefaultOptions = (field: FieldFromSchema) => {
  134. const savedOption = ((this.props.resetValues || {}).settings || []).find(
  135. value => value.name === field.name
  136. );
  137. const currentOptions = (field.choices || []).map(([value, label]) => ({
  138. value,
  139. label,
  140. }));
  141. const shouldAddSavedOption =
  142. // We only render saved options if they have preserved the label, otherwise it appears unselcted.
  143. // The next time the user saves, the label should be preserved.
  144. savedOption?.value &&
  145. savedOption?.label &&
  146. // The option isn't in the current options already
  147. !currentOptions.some(option => option.value === savedOption?.value);
  148. return shouldAddSavedOption
  149. ? [{value: savedOption.value, label: savedOption.label}, ...currentOptions]
  150. : currentOptions;
  151. };
  152. getDefaultFieldValue = (field: FieldFromSchema) => {
  153. // Interpret the default if a getFieldDefault function is provided.
  154. const {resetValues, getFieldDefault} = this.props;
  155. let defaultValue = field?.defaultValue;
  156. // Override this default if a reset value is provided
  157. if (field.default && getFieldDefault) {
  158. defaultValue = getFieldDefault(field);
  159. }
  160. const reset = ((resetValues || {}).settings || []).find(
  161. value => value.name === field.name
  162. );
  163. if (reset) {
  164. defaultValue = reset.value;
  165. }
  166. return defaultValue;
  167. };
  168. debouncedOptionLoad = debounce(
  169. // debounce is used to prevent making a request for every input change and
  170. // instead makes the requests every 200ms
  171. async (field: FieldFromSchema, input, resolve) => {
  172. const choices = await this.makeExternalRequest(field, input);
  173. const options = choices.map(([value, label]) => ({value, label}));
  174. const optionsByField = new Map(this.state.optionsByField);
  175. optionsByField.set(field.name, options);
  176. this.setState({
  177. optionsByField,
  178. });
  179. return resolve(options);
  180. },
  181. 200,
  182. {trailing: true}
  183. );
  184. makeExternalRequest = async (field: FieldFromSchema, input: FieldValue) => {
  185. const {extraRequestBody = {}, sentryAppInstallationUuid} = this.props;
  186. const query: {[key: string]: any} = {
  187. ...extraRequestBody,
  188. uri: field.uri,
  189. query: input,
  190. };
  191. if (field.depends_on) {
  192. const dependentData = field.depends_on.reduce((accum, dependentField: string) => {
  193. accum[dependentField] = this.model.getValue(dependentField);
  194. return accum;
  195. }, {});
  196. // stringify the data
  197. query.dependentData = JSON.stringify(dependentData);
  198. }
  199. const {choices} = await this.props.api.requestPromise(
  200. `/sentry-app-installations/${sentryAppInstallationUuid}/external-requests/`,
  201. {query}
  202. );
  203. return choices || [];
  204. };
  205. /**
  206. * This function determines which fields need to be reset and new options fetched
  207. * based on the dependencies defined with the depends_on attribute.
  208. * This is done because the autoload flag causes fields to load at different times
  209. * if you have multiple dependent fields while this solution updates state at once.
  210. */
  211. handleFieldChange = async (id: string) => {
  212. const config = this.state;
  213. let requiredFields = config.required_fields || [];
  214. let optionalFields = config.optional_fields || [];
  215. const fieldList: FieldFromSchema[] = requiredFields.concat(optionalFields);
  216. // could have multiple impacted fields
  217. const impactedFields = fieldList.filter(({depends_on}) => {
  218. if (!depends_on) {
  219. return false;
  220. }
  221. // must be dependent on the field we just set
  222. return depends_on.includes(id);
  223. });
  224. // load all options in parallel
  225. const choiceArray = await Promise.all(
  226. impactedFields.map(field => {
  227. // reset all impacted fields first
  228. this.model.setValue(field.name || '', '', {quiet: true});
  229. return this.makeExternalRequest(field, '');
  230. })
  231. );
  232. this.setState(state => {
  233. // pull the field lists from latest state
  234. requiredFields = state.required_fields || [];
  235. optionalFields = state.optional_fields || [];
  236. // iterate through all the impacted fields and get new values
  237. impactedFields.forEach((impactedField, i) => {
  238. const choices = choiceArray[i];
  239. const requiredIndex = requiredFields.indexOf(impactedField);
  240. const optionalIndex = optionalFields.indexOf(impactedField);
  241. const updatedField = {...impactedField, choices};
  242. // immutably update the lists with the updated field depending where we got it from
  243. if (requiredIndex > -1) {
  244. requiredFields = replaceAtArrayIndex(
  245. requiredFields,
  246. requiredIndex,
  247. updatedField
  248. );
  249. } else if (optionalIndex > -1) {
  250. optionalFields = replaceAtArrayIndex(
  251. optionalFields,
  252. optionalIndex,
  253. updatedField
  254. );
  255. }
  256. });
  257. return {
  258. required_fields: requiredFields,
  259. optional_fields: optionalFields,
  260. };
  261. });
  262. };
  263. createPreserveOptionFunction = (name: string) => (option, _event) => {
  264. this.setState({
  265. selectedOptions: {
  266. ...this.state.selectedOptions,
  267. [name]: option,
  268. },
  269. });
  270. };
  271. renderField = (field: FieldFromSchema, required: boolean) => {
  272. // This function converts the field we get from the backend into
  273. // the field we need to pass down
  274. let fieldToPass: Field = {
  275. ...field,
  276. inline: false,
  277. stacked: true,
  278. flexibleControlStateSize: true,
  279. required,
  280. };
  281. if (field?.uri && field?.async) {
  282. fieldToPass.type = 'select_async';
  283. }
  284. if (['select', 'select_async'].includes(fieldToPass.type || '')) {
  285. // find the options from state to pass down
  286. const defaultOptions = this.getDefaultOptions(field);
  287. const options = this.state.optionsByField.get(field.name) || defaultOptions;
  288. fieldToPass = {
  289. ...fieldToPass,
  290. options,
  291. defaultOptions,
  292. defaultValue: this.getDefaultFieldValue(field),
  293. // filter by what the user is typing
  294. filterOption: createFilter({}),
  295. allowClear: !required,
  296. placeholder: 'Type to search',
  297. } as Field;
  298. if (field.depends_on) {
  299. // check if this is dependent on other fields which haven't been set yet
  300. const shouldDisable = field.depends_on.some(
  301. dependentField => !hasValue(this.model.getValue(dependentField))
  302. );
  303. if (shouldDisable) {
  304. fieldToPass = {...fieldToPass, disabled: true};
  305. }
  306. }
  307. }
  308. if (['text', 'textarea'].includes(fieldToPass.type || '')) {
  309. fieldToPass = {
  310. ...fieldToPass,
  311. defaultValue: this.getDefaultFieldValue(field),
  312. };
  313. }
  314. // if we have a uri, we need to set extra parameters
  315. const extraProps = field.uri
  316. ? {
  317. loadOptions: (input: string) => this.getOptions(field, input),
  318. async: field?.async ?? true,
  319. cache: false,
  320. onSelectResetsInput: false,
  321. onCloseResetsInput: false,
  322. onBlurResetsInput: false,
  323. autoload: false,
  324. onChangeOption: this.createPreserveOptionFunction(field.name),
  325. }
  326. : {};
  327. return (
  328. <FieldFromConfig
  329. key={field.name}
  330. field={fieldToPass}
  331. data-test-id={field.name}
  332. {...extraProps}
  333. />
  334. );
  335. };
  336. handleAlertRuleSubmit = (formData, onSubmitSuccess) => {
  337. const {sentryAppInstallationUuid} = this.props;
  338. if (this.model.validateForm()) {
  339. onSubmitSuccess({
  340. // The form data must be nested in 'settings' to ensure they don't overlap with any other field names.
  341. settings: Object.entries(formData).map(([name, value]) => {
  342. const savedSetting: SentryAppSetting = {name, value};
  343. const stateOption = this.state.selectedOptions[name];
  344. // If the field is a SelectAsync, we need to preserve the label since the next time it's rendered,
  345. // we can't be sure the options will contain this selection
  346. if (stateOption?.value === value) {
  347. savedSetting.label = `${stateOption?.label}`;
  348. }
  349. return savedSetting;
  350. }),
  351. sentryAppInstallationUuid,
  352. // Used on the backend to explicitly associate with a different rule than those without a custom form.
  353. hasSchemaFormConfig: true,
  354. });
  355. }
  356. };
  357. render() {
  358. const {sentryAppInstallationUuid, action, element, onSubmitSuccess} = this.props;
  359. const requiredFields = this.state.required_fields || [];
  360. const optionalFields = this.state.optional_fields || [];
  361. if (!sentryAppInstallationUuid) {
  362. return '';
  363. }
  364. return (
  365. <Form
  366. key={action}
  367. apiEndpoint={`/sentry-app-installations/${sentryAppInstallationUuid}/external-issue-actions/`}
  368. apiMethod="POST"
  369. // Without defining onSubmit, the Form will send an `apiMethod` request to the above `apiEndpoint`
  370. onSubmit={
  371. element === 'alert-rule-action' ? this.handleAlertRuleSubmit : undefined
  372. }
  373. onSubmitSuccess={(...params) => {
  374. onSubmitSuccess(...params);
  375. }}
  376. onSubmitError={this.onSubmitError}
  377. onFieldChange={this.handleFieldChange}
  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);