sentryAppExternalForm.tsx 12 KB

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