abstractExternalIssueForm.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. import {Fragment} from 'react';
  2. import debounce from 'lodash/debounce';
  3. import * as qs from 'query-string';
  4. import {ModalRenderProps} from 'sentry/actionCreators/modal';
  5. import AsyncComponent from 'sentry/components/asyncComponent';
  6. import FieldFromConfig from 'sentry/components/forms/fieldFromConfig';
  7. import Form, {FormProps} from 'sentry/components/forms/form';
  8. import FormModel, {FieldValue} from 'sentry/components/forms/model';
  9. import QuestionTooltip from 'sentry/components/questionTooltip';
  10. import {tct} from 'sentry/locale';
  11. import {
  12. Choices,
  13. IntegrationIssueConfig,
  14. IssueConfigField,
  15. SelectValue,
  16. } from 'sentry/types';
  17. import {FormField} from 'sentry/views/alerts/rules/issue/ruleNode';
  18. export type ExternalIssueAction = 'create' | 'link';
  19. export type ExternalIssueFormErrors = {[key: string]: React.ReactNode};
  20. type Props = ModalRenderProps & AsyncComponent['props'];
  21. type State = {
  22. action: ExternalIssueAction;
  23. /**
  24. * Object of fields where `updatesFrom` is true, by field name. Derived from
  25. * `integrationDetails` when it loads. Null until set.
  26. */
  27. dynamicFieldValues: {[key: string]: FieldValue | null} | null;
  28. /**
  29. * Cache of options fetched for async fields.
  30. */
  31. fetchedFieldOptionsCache: Record<string, Choices>;
  32. /**
  33. * Fetched via endpoint, null until set.
  34. */
  35. integrationDetails: IntegrationIssueConfig | null;
  36. } & AsyncComponent['state'];
  37. const DEBOUNCE_MS = 200;
  38. /**
  39. * @abstract
  40. */
  41. export default class AbstractExternalIssueForm<
  42. P extends Props = Props,
  43. S extends State = State
  44. > extends AsyncComponent<P, S> {
  45. shouldRenderBadRequests = true;
  46. model = new FormModel();
  47. getDefaultState(): State {
  48. return {
  49. ...super.getDefaultState(),
  50. action: 'create',
  51. dynamicFieldValues: null,
  52. fetchedFieldOptionsCache: {},
  53. integrationDetails: null,
  54. };
  55. }
  56. refetchConfig = () => {
  57. const {action, dynamicFieldValues} = this.state;
  58. const query = {action, ...dynamicFieldValues};
  59. const endpoint = this.getEndPointString();
  60. this.api.request(endpoint, {
  61. method: 'GET',
  62. query,
  63. success: (data, _, resp) => {
  64. this.handleRequestSuccess({stateKey: 'integrationDetails', data, resp}, true);
  65. },
  66. error: error => {
  67. this.handleError(error, ['integrationDetails', endpoint, null, null]);
  68. },
  69. });
  70. };
  71. getConfigName = (): 'createIssueConfig' | 'linkIssueConfig' => {
  72. // Explicitly returning a non-interpolated string for clarity.
  73. const {action} = this.state;
  74. switch (action) {
  75. case 'create':
  76. return 'createIssueConfig';
  77. case 'link':
  78. return 'linkIssueConfig';
  79. default:
  80. throw new Error('illegal action');
  81. }
  82. };
  83. /**
  84. * Convert IntegrationIssueConfig to an object that maps field names to the
  85. * values of fields where `updatesFrom` is true. This function prefers to read
  86. * configs from its parameters and otherwise falls back to reading from state.
  87. * @param integrationDetailsParam
  88. * @returns Object of field names to values.
  89. */
  90. getDynamicFields = (
  91. integrationDetailsParam?: IntegrationIssueConfig
  92. ): {[key: string]: FieldValue | null} => {
  93. const {integrationDetails: integrationDetailsFromState} = this.state;
  94. const integrationDetails = integrationDetailsParam || integrationDetailsFromState;
  95. const config = (integrationDetails || {})[this.getConfigName()];
  96. return Object.fromEntries(
  97. (config || [])
  98. .filter((field: IssueConfigField) => field.updatesForm)
  99. .map((field: IssueConfigField) => [field.name, field.default || null])
  100. );
  101. };
  102. onRequestSuccess = ({stateKey, data}) => {
  103. if (stateKey === 'integrationDetails') {
  104. this.handleReceiveIntegrationDetails(data);
  105. this.setState({
  106. dynamicFieldValues: this.getDynamicFields(data),
  107. });
  108. }
  109. };
  110. /**
  111. * If this field should updateForm, updateForm. Otherwise, do nothing.
  112. */
  113. onFieldChange = (fieldName: string, value: FieldValue) => {
  114. const {dynamicFieldValues} = this.state;
  115. const dynamicFields = this.getDynamicFields();
  116. if (dynamicFields.hasOwnProperty(fieldName) && dynamicFieldValues) {
  117. dynamicFieldValues[fieldName] = value;
  118. this.setState(
  119. {
  120. dynamicFieldValues,
  121. reloading: true,
  122. error: false,
  123. remainingRequests: 1,
  124. },
  125. this.refetchConfig
  126. );
  127. }
  128. };
  129. /**
  130. * For fields with dynamic fields, cache the fetched choices.
  131. */
  132. updateFetchedFieldOptionsCache = (
  133. field: IssueConfigField,
  134. result: SelectValue<string | number>[]
  135. ): void => {
  136. const {fetchedFieldOptionsCache} = this.state;
  137. this.setState({
  138. fetchedFieldOptionsCache: {
  139. ...fetchedFieldOptionsCache,
  140. [field.name]: result.map(obj => [obj.value, obj.label]),
  141. },
  142. });
  143. };
  144. /**
  145. * Ensures current result from Async select fields is never discarded. Without this method,
  146. * searching in an async select field without selecting one of the returned choices will
  147. * result in a value saved to the form, and no associated label; appearing empty.
  148. * @param field The field being examined
  149. * @param result The result from it's asynchronous query
  150. * @returns The result with a tooltip attached to the current option
  151. */
  152. ensureCurrentOption = (
  153. field: IssueConfigField,
  154. result: SelectValue<string | number>[]
  155. ): SelectValue<string | number>[] => {
  156. const currentOption = this.getDefaultOptions(field).find(
  157. option => option.value === this.model.getValue(field.name)
  158. );
  159. if (!currentOption) {
  160. return result;
  161. }
  162. if (typeof currentOption.label === 'string') {
  163. currentOption.label = (
  164. <Fragment>
  165. <QuestionTooltip
  166. title={tct('This is your current [label].', {
  167. label: field.label,
  168. })}
  169. size="xs"
  170. />{' '}
  171. {currentOption.label}
  172. </Fragment>
  173. );
  174. }
  175. const currentOptionResultIndex = result.findIndex(
  176. obj => obj.value === currentOption?.value
  177. );
  178. // Has a selected option, and it is in API results
  179. if (currentOptionResultIndex >= 0) {
  180. const newResult = result;
  181. newResult[currentOptionResultIndex] = currentOption;
  182. return newResult;
  183. }
  184. // Has a selected option, and it is not in API results
  185. return [...result, currentOption];
  186. };
  187. /**
  188. * Get the list of options for a field via debounced API call. For example,
  189. * the list of users that match the input string. The Promise rejects if there
  190. * are any errors.
  191. */
  192. getOptions = (field: IssueConfigField, input: string) =>
  193. new Promise((resolve, reject) => {
  194. if (!input) {
  195. return resolve(this.getDefaultOptions(field));
  196. }
  197. return this.debouncedOptionLoad(field, input, (err, result) => {
  198. if (err) {
  199. reject(err);
  200. } else {
  201. result = this.ensureCurrentOption(field, result);
  202. this.updateFetchedFieldOptionsCache(field, result);
  203. resolve(result);
  204. }
  205. });
  206. });
  207. debouncedOptionLoad = debounce(
  208. async (
  209. field: IssueConfigField,
  210. input: string,
  211. cb: (err: Error | null, result?: any) => void
  212. ) => {
  213. const {dynamicFieldValues} = this.state;
  214. const query = qs.stringify({
  215. ...dynamicFieldValues,
  216. field: field.name,
  217. query: input,
  218. });
  219. const url = field.url || '';
  220. const separator = url.includes('?') ? '&' : '?';
  221. // We can't use the API client here since the URL is not scoped under the
  222. // API endpoints (which the client prefixes)
  223. try {
  224. const response = await fetch(url + separator + query);
  225. cb(null, response.ok ? await response.json() : []);
  226. } catch (err) {
  227. cb(err);
  228. }
  229. },
  230. DEBOUNCE_MS,
  231. {trailing: true}
  232. );
  233. getDefaultOptions = (field: IssueConfigField) => {
  234. const choices =
  235. (field.choices as Array<[number | string, number | string | React.ReactElement]>) ||
  236. [];
  237. return choices.map(([value, label]) => ({value, label}));
  238. };
  239. /**
  240. * If this field is an async select (field.url is not null), add async props.
  241. */
  242. getFieldProps = (field: IssueConfigField) =>
  243. field.url
  244. ? {
  245. async: true,
  246. autoload: true,
  247. cache: false,
  248. loadOptions: (input: string) => this.getOptions(field, input),
  249. defaultOptions: this.getDefaultOptions(field),
  250. onBlurResetsInput: false,
  251. onCloseResetsInput: false,
  252. onSelectResetsInput: false,
  253. }
  254. : {};
  255. // Abstract methods.
  256. handleReceiveIntegrationDetails = (_data: any) => {
  257. // Do nothing.
  258. };
  259. getEndPointString(): string {
  260. throw new Error("Method 'getEndPointString()' must be implemented.");
  261. }
  262. renderNavTabs = (): React.ReactNode => null;
  263. renderBodyText = (): React.ReactNode => null;
  264. getTitle = () => tct('Issue Link Settings', {});
  265. getFormProps = (): FormProps => {
  266. throw new Error("Method 'getFormProps()' must be implemented.");
  267. };
  268. getDefaultFormProps = (): FormProps => {
  269. return {
  270. footerClass: 'modal-footer',
  271. onFieldChange: this.onFieldChange,
  272. submitDisabled: this.state.reloading,
  273. model: this.model,
  274. // Other form props implemented by child classes.
  275. };
  276. };
  277. getCleanedFields = (): IssueConfigField[] => {
  278. const {fetchedFieldOptionsCache, integrationDetails} = this.state;
  279. const configsFromAPI = (integrationDetails || {})[this.getConfigName()];
  280. return (configsFromAPI || []).map(field => {
  281. const fieldCopy = {...field};
  282. // Overwrite choices from cache.
  283. if (fetchedFieldOptionsCache?.hasOwnProperty(field.name)) {
  284. fieldCopy.choices = fetchedFieldOptionsCache[field.name];
  285. }
  286. return fieldCopy;
  287. });
  288. };
  289. renderComponent() {
  290. return this.state.error
  291. ? this.renderError(new Error('Unable to load all required endpoints'))
  292. : this.renderBody();
  293. }
  294. renderForm = (
  295. formFields?: IssueConfigField[],
  296. errors: ExternalIssueFormErrors = {}
  297. ) => {
  298. const initialData: {[key: string]: any} = (formFields || []).reduce(
  299. (accumulator, field: FormField) => {
  300. accumulator[field.name] =
  301. // Passing an empty array breaks MultiSelect.
  302. field.multiple && field.default.length === 0 ? '' : field.default;
  303. return accumulator;
  304. },
  305. {}
  306. );
  307. const {Header, Body} = this.props as ModalRenderProps;
  308. return (
  309. <Fragment>
  310. <Header closeButton>{this.getTitle()}</Header>
  311. {this.renderNavTabs()}
  312. <Body>
  313. {this.shouldRenderLoading ? (
  314. this.renderLoading()
  315. ) : (
  316. <Fragment>
  317. {this.renderBodyText()}
  318. <Form initialData={initialData} {...this.getFormProps()}>
  319. {(formFields || [])
  320. .filter((field: FormField) => field.hasOwnProperty('name'))
  321. .map(fields => ({
  322. ...fields,
  323. noOptionsMessage: () => 'No options. Type to search.',
  324. }))
  325. .map((field, i) => {
  326. return (
  327. <Fragment key={`${field.name}-${i}`}>
  328. <FieldFromConfig
  329. disabled={this.state.reloading}
  330. field={field}
  331. flexibleControlStateSize
  332. inline={false}
  333. stacked
  334. {...this.getFieldProps(field)}
  335. />
  336. {errors[field.name] && errors[field.name]}
  337. </Fragment>
  338. );
  339. })}
  340. </Form>
  341. </Fragment>
  342. )}
  343. </Body>
  344. </Fragment>
  345. );
  346. };
  347. }