issueActions.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  1. import Form from 'sentry/components/deprecatedforms/form';
  2. import FormState from 'sentry/components/forms/state';
  3. import LoadingError from 'sentry/components/loadingError';
  4. import LoadingIndicator from 'sentry/components/loadingIndicator';
  5. import {t} from 'sentry/locale';
  6. import PluginComponentBase from 'sentry/plugins/pluginComponentBase';
  7. import GroupStore from 'sentry/stores/groupStore';
  8. import type {Group} from 'sentry/types/group';
  9. import type {Plugin} from 'sentry/types/integrations';
  10. import type {Organization} from 'sentry/types/organization';
  11. import type {Project} from 'sentry/types/project';
  12. import {trackAnalytics} from 'sentry/utils/analytics';
  13. import {getAnalyticsDataForGroup} from 'sentry/utils/events';
  14. type Field = {
  15. depends?: string[];
  16. has_autocomplete?: boolean;
  17. } & Parameters<typeof PluginComponentBase.prototype.renderField>[0]['config'];
  18. type ActionType = 'link' | 'create' | 'unlink';
  19. type FieldStateValue = (typeof FormState)[keyof typeof FormState];
  20. type Props = {
  21. actionType: ActionType;
  22. group: Group;
  23. organization: Organization;
  24. plugin: Plugin & {
  25. issue?: {
  26. issue_id: string;
  27. label: string;
  28. url: string;
  29. };
  30. };
  31. project: Project;
  32. onError?: (data: any) => void;
  33. onSuccess?: (data: any) => void;
  34. };
  35. type State = {
  36. createFormData: Record<string, any>;
  37. dependentFieldState: Record<string, FieldStateValue>;
  38. linkFormData: Record<string, any>;
  39. unlinkFormData: Record<string, any>;
  40. createFieldList?: Field[];
  41. error?: {
  42. message: string;
  43. auth_url?: string;
  44. error_type?: string;
  45. errors?: Record<string, string>;
  46. has_auth_configured?: boolean;
  47. required_auth_settings?: string[];
  48. };
  49. linkFieldList?: Field[];
  50. loading?: boolean;
  51. unlinkFieldList?: Field[];
  52. } & PluginComponentBase['state'];
  53. class IssueActions extends PluginComponentBase<Props, State> {
  54. constructor(props: Props, context) {
  55. super(props, context);
  56. this.createIssue = this.onSave.bind(this, this.createIssue.bind(this));
  57. this.linkIssue = this.onSave.bind(this, this.linkIssue.bind(this));
  58. this.unlinkIssue = this.onSave.bind(this, this.unlinkIssue.bind(this));
  59. this.onSuccess = this.onSaveSuccess.bind(this, this.onSuccess.bind(this));
  60. this.errorHandler = this.onLoadError.bind(this, this.errorHandler.bind(this));
  61. this.state = {
  62. ...this.state,
  63. loading: ['link', 'create'].includes(this.props.actionType),
  64. state: ['link', 'create'].includes(this.props.actionType)
  65. ? FormState.LOADING
  66. : FormState.READY,
  67. createFormData: {},
  68. linkFormData: {},
  69. dependentFieldState: {},
  70. };
  71. }
  72. getGroup() {
  73. return this.props.group;
  74. }
  75. getProject() {
  76. return this.props.project;
  77. }
  78. getOrganization() {
  79. return this.props.organization;
  80. }
  81. getFieldListKey() {
  82. switch (this.props.actionType) {
  83. case 'link':
  84. return 'linkFieldList';
  85. case 'unlink':
  86. return 'unlinkFieldList';
  87. case 'create':
  88. return 'createFieldList';
  89. default:
  90. throw new Error('Unexpeced action type');
  91. }
  92. }
  93. getFormDataKey(actionType?: ActionType) {
  94. switch (actionType || this.props.actionType) {
  95. case 'link':
  96. return 'linkFormData';
  97. case 'unlink':
  98. return 'unlinkFormData';
  99. case 'create':
  100. return 'createFormData';
  101. default:
  102. throw new Error('Unexpeced action type');
  103. }
  104. }
  105. getFormData() {
  106. const key = this.getFormDataKey();
  107. return this.state[key] || {};
  108. }
  109. getFieldList() {
  110. const key = this.getFieldListKey();
  111. return this.state[key] || [];
  112. }
  113. componentDidMount() {
  114. const plugin = this.props.plugin;
  115. if (!plugin.issue && this.props.actionType !== 'unlink') {
  116. this.fetchData();
  117. }
  118. }
  119. getPluginCreateEndpoint() {
  120. return (
  121. '/issues/' + this.getGroup().id + '/plugins/' + this.props.plugin.slug + '/create/'
  122. );
  123. }
  124. getPluginLinkEndpoint() {
  125. return (
  126. '/issues/' + this.getGroup().id + '/plugins/' + this.props.plugin.slug + '/link/'
  127. );
  128. }
  129. getPluginUnlinkEndpoint() {
  130. return (
  131. '/issues/' + this.getGroup().id + '/plugins/' + this.props.plugin.slug + '/unlink/'
  132. );
  133. }
  134. setDependentFieldState(fieldName, state) {
  135. const dependentFieldState = {...this.state.dependentFieldState, [fieldName]: state};
  136. this.setState({dependentFieldState});
  137. }
  138. loadOptionsForDependentField = async field => {
  139. const formData = this.getFormData();
  140. const groupId = this.getGroup().id;
  141. const pluginSlug = this.props.plugin.slug;
  142. const url = `/issues/${groupId}/plugins/${pluginSlug}/options/`;
  143. // find the fields that this field is dependent on
  144. const dependentFormValues = Object.fromEntries(
  145. field.depends.map(fieldKey => [fieldKey, formData[fieldKey]])
  146. );
  147. const query = {
  148. option_field: field.name,
  149. ...dependentFormValues,
  150. };
  151. try {
  152. this.setDependentFieldState(field.name, FormState.LOADING);
  153. const result = await this.api.requestPromise(url, {query});
  154. this.updateOptionsOfDependentField(field, result[field.name]);
  155. this.setDependentFieldState(field.name, FormState.READY);
  156. } catch (err) {
  157. this.setDependentFieldState(field.name, FormState.ERROR);
  158. this.errorHandler(err);
  159. }
  160. };
  161. updateOptionsOfDependentField = (field: Field, choices: Field['choices']) => {
  162. const formListKey = this.getFieldListKey();
  163. let fieldList = this.state[formListKey];
  164. if (!fieldList) {
  165. return;
  166. }
  167. // find the location of the field in our list and replace it
  168. const indexOfField = fieldList.findIndex(({name}) => name === field.name);
  169. field = {...field, choices};
  170. // make a copy of the array to avoid mutation
  171. fieldList = fieldList.slice();
  172. fieldList[indexOfField] = field;
  173. this.setState(prevState => ({...prevState, [formListKey]: fieldList}));
  174. };
  175. resetOptionsOfDependentField = (field: Field) => {
  176. this.updateOptionsOfDependentField(field, []);
  177. const formDataKey = this.getFormDataKey();
  178. const formData = {...this.state[formDataKey]};
  179. formData[field.name] = '';
  180. this.setState(prevState => ({...prevState, [formDataKey]: formData}));
  181. this.setDependentFieldState(field.name, FormState.DISABLED);
  182. };
  183. getInputProps(field: Field) {
  184. const props: {isLoading?: boolean; readonly?: boolean} = {};
  185. // special logic for fields that have dependencies
  186. if (field.depends && field.depends.length > 0) {
  187. switch (this.state.dependentFieldState[field.name]) {
  188. case FormState.LOADING:
  189. props.isLoading = true;
  190. props.readonly = true;
  191. break;
  192. case FormState.DISABLED:
  193. case FormState.ERROR:
  194. props.readonly = true;
  195. break;
  196. default:
  197. break;
  198. }
  199. }
  200. return props;
  201. }
  202. setError(error, defaultMessage: string) {
  203. let errorBody;
  204. if (error.status === 400 && error.responseJSON) {
  205. errorBody = error.responseJSON;
  206. } else {
  207. errorBody = {message: defaultMessage};
  208. }
  209. this.setState({error: errorBody});
  210. }
  211. errorHandler(error) {
  212. const state: Pick<State, 'loading' | 'error'> = {
  213. loading: false,
  214. };
  215. if (error.status === 400 && error.responseJSON) {
  216. state.error = error.responseJSON;
  217. } else {
  218. state.error = {message: t('An unknown error occurred.')};
  219. }
  220. this.setState(state);
  221. }
  222. onLoadSuccess() {
  223. super.onLoadSuccess();
  224. // dependent fields need to be set to disabled upon loading
  225. const fieldList = this.getFieldList();
  226. fieldList.forEach(field => {
  227. if (field.depends && field.depends.length > 0) {
  228. this.setDependentFieldState(field.name, FormState.DISABLED);
  229. }
  230. });
  231. }
  232. fetchData() {
  233. if (this.props.actionType === 'create') {
  234. this.api.request(this.getPluginCreateEndpoint(), {
  235. success: data => {
  236. const createFormData = {};
  237. data.forEach(field => {
  238. createFormData[field.name] = field.default;
  239. });
  240. this.setState(
  241. {
  242. createFieldList: data,
  243. error: undefined,
  244. loading: false,
  245. createFormData,
  246. },
  247. this.onLoadSuccess
  248. );
  249. },
  250. error: this.errorHandler,
  251. });
  252. } else if (this.props.actionType === 'link') {
  253. this.api.request(this.getPluginLinkEndpoint(), {
  254. success: data => {
  255. const linkFormData = {};
  256. data.forEach(field => {
  257. linkFormData[field.name] = field.default;
  258. });
  259. this.setState(
  260. {
  261. linkFieldList: data,
  262. error: undefined,
  263. loading: false,
  264. linkFormData,
  265. },
  266. this.onLoadSuccess
  267. );
  268. },
  269. error: this.errorHandler,
  270. });
  271. }
  272. }
  273. onSuccess(data) {
  274. // TODO(ts): This needs a better approach. We splice in this attribute to trigger
  275. // a refetch in GroupDetails
  276. type StaleGroup = Group & {stale?: boolean};
  277. trackAnalytics('issue_details.external_issue_created', {
  278. organization: this.props.organization,
  279. ...getAnalyticsDataForGroup(this.props.group),
  280. external_issue_provider: this.props.plugin.slug,
  281. external_issue_type: 'plugin',
  282. });
  283. GroupStore.onUpdateSuccess('', [this.getGroup().id], {stale: true} as StaleGroup);
  284. this.props.onSuccess?.(data);
  285. }
  286. createIssue() {
  287. this.api.request(this.getPluginCreateEndpoint(), {
  288. data: this.state.createFormData,
  289. success: this.onSuccess,
  290. error: this.onSaveError.bind(this, error => {
  291. this.setError(error, t('There was an error creating the issue.'));
  292. }),
  293. complete: this.onSaveComplete,
  294. });
  295. }
  296. linkIssue() {
  297. this.api.request(this.getPluginLinkEndpoint(), {
  298. data: this.state.linkFormData,
  299. success: this.onSuccess,
  300. error: this.onSaveError.bind(this, error => {
  301. this.setError(error, t('There was an error linking the issue.'));
  302. }),
  303. complete: this.onSaveComplete,
  304. });
  305. }
  306. unlinkIssue() {
  307. this.api.request(this.getPluginUnlinkEndpoint(), {
  308. success: this.onSuccess,
  309. error: this.onSaveError.bind(this, error => {
  310. this.setError(error, t('There was an error unlinking the issue.'));
  311. }),
  312. complete: this.onSaveComplete,
  313. });
  314. }
  315. changeField(action: ActionType, name: string, value: any) {
  316. const formDataKey = this.getFormDataKey(action);
  317. // copy so we don't mutate
  318. const formData = {...this.state[formDataKey]};
  319. const fieldList = this.getFieldList();
  320. formData[name] = value;
  321. let callback = () => {};
  322. // only works with one impacted field
  323. const impactedField = fieldList.find(({depends}) => {
  324. if (!depends || !depends.length) {
  325. return false;
  326. }
  327. // must be dependent on the field we just set
  328. return depends.includes(name);
  329. });
  330. if (impactedField) {
  331. // if every dependent field is set, then search
  332. if (!impactedField.depends?.some(dependentField => !formData[dependentField])) {
  333. callback = () => this.loadOptionsForDependentField(impactedField);
  334. } else {
  335. // otherwise reset the options
  336. callback = () => this.resetOptionsOfDependentField(impactedField);
  337. }
  338. }
  339. this.setState(prevState => ({...prevState, [formDataKey]: formData}), callback);
  340. }
  341. renderForm(): React.ReactNode {
  342. switch (this.props.actionType) {
  343. case 'create':
  344. if (this.state.createFieldList) {
  345. return (
  346. <Form
  347. onSubmit={this.createIssue}
  348. submitLabel={t('Create Issue')}
  349. footerClass=""
  350. >
  351. {this.state.createFieldList.map(field => {
  352. if (field.has_autocomplete) {
  353. field = Object.assign(
  354. {
  355. url:
  356. '/api/0/issues/' +
  357. this.getGroup().id +
  358. '/plugins/' +
  359. this.props.plugin.slug +
  360. '/autocomplete',
  361. },
  362. field
  363. );
  364. }
  365. return (
  366. <div key={field.name}>
  367. {this.renderField({
  368. config: {...field, ...this.getInputProps(field)},
  369. formData: this.state.createFormData,
  370. onChange: this.changeField.bind(this, 'create', field.name),
  371. })}
  372. </div>
  373. );
  374. })}
  375. </Form>
  376. );
  377. }
  378. break;
  379. case 'link':
  380. if (this.state.linkFieldList) {
  381. return (
  382. <Form onSubmit={this.linkIssue} submitLabel={t('Link Issue')} footerClass="">
  383. {this.state.linkFieldList.map(field => {
  384. if (field.has_autocomplete) {
  385. field = Object.assign(
  386. {
  387. url:
  388. '/api/0/issues/' +
  389. this.getGroup().id +
  390. '/plugins/' +
  391. this.props.plugin.slug +
  392. '/autocomplete',
  393. },
  394. field
  395. );
  396. }
  397. return (
  398. <div key={field.name}>
  399. {this.renderField({
  400. config: {...field, ...this.getInputProps(field)},
  401. formData: this.state.linkFormData,
  402. onChange: this.changeField.bind(this, 'link', field.name),
  403. })}
  404. </div>
  405. );
  406. })}
  407. </Form>
  408. );
  409. }
  410. break;
  411. case 'unlink':
  412. return (
  413. <div>
  414. <p>{t('Are you sure you want to unlink this issue?')}</p>
  415. <button onClick={this.unlinkIssue} className="btn btn-danger">
  416. {t('Unlink Issue')}
  417. </button>
  418. </div>
  419. );
  420. default:
  421. return null;
  422. }
  423. return null;
  424. }
  425. getPluginConfigureUrl() {
  426. const org = this.getOrganization();
  427. const project = this.getProject();
  428. const plugin = this.props.plugin;
  429. return '/' + org.slug + '/' + project.slug + '/settings/plugins/' + plugin.slug;
  430. }
  431. renderError() {
  432. const error = this.state.error;
  433. if (!error) {
  434. return null;
  435. }
  436. if (error.error_type === 'auth') {
  437. let authUrl = error.auth_url;
  438. if (authUrl?.indexOf('?') === -1) {
  439. authUrl += '?next=' + encodeURIComponent(document.location.pathname);
  440. } else {
  441. authUrl += '&next=' + encodeURIComponent(document.location.pathname);
  442. }
  443. return (
  444. <div>
  445. <div className="alert alert-warning m-b-1">
  446. {'You need to associate an identity with ' +
  447. this.props.plugin.name +
  448. ' before you can create issues with this service.'}
  449. </div>
  450. <a className="btn btn-primary" href={authUrl}>
  451. Associate Identity
  452. </a>
  453. </div>
  454. );
  455. }
  456. if (error.error_type === 'config') {
  457. return (
  458. <div className="alert alert-block">
  459. {!error.has_auth_configured ? (
  460. <div>
  461. <p>
  462. {'Your server administrator will need to configure authentication with '}
  463. <strong>{this.props.plugin.name}</strong>
  464. {' before you can use this integration.'}
  465. </p>
  466. <p>The following settings must be configured:</p>
  467. <ul>
  468. {error.required_auth_settings?.map((setting, i) => (
  469. <li key={i}>
  470. <code>{setting}</code>
  471. </li>
  472. ))}
  473. </ul>
  474. </div>
  475. ) : (
  476. <p>
  477. You still need to{' '}
  478. <a href={this.getPluginConfigureUrl()}>configure this plugin</a> before you
  479. can use it.
  480. </p>
  481. )}
  482. </div>
  483. );
  484. }
  485. if (error.error_type === 'validation') {
  486. const errors: React.ReactElement[] = [];
  487. for (const name in error.errors) {
  488. errors.push(<p key={name}>{error.errors[name]}</p>);
  489. }
  490. return <div className="alert alert-error alert-block">{errors}</div>;
  491. }
  492. if (error.message) {
  493. return (
  494. <div className="alert alert-error alert-block">
  495. <p>{error.message}</p>
  496. </div>
  497. );
  498. }
  499. return <LoadingError />;
  500. }
  501. render() {
  502. if (this.state.state === FormState.LOADING) {
  503. return <LoadingIndicator />;
  504. }
  505. return (
  506. <div>
  507. {this.renderError()}
  508. {this.renderForm()}
  509. </div>
  510. );
  511. }
  512. }
  513. export default IssueActions;