abstractExternalIssueForm.tsx 12 KB

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