issueActions.tsx 16 KB

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