issueActions.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567
  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: any) {
  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: any, state: any) {
  135. const dependentFieldState = {...this.state.dependentFieldState, [fieldName]: state};
  136. this.setState({dependentFieldState});
  137. }
  138. loadOptionsForDependentField = async (field: any) => {
  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: any) => [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: any, defaultMessage: string) {
  203. let errorBody: any;
  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: any) {
  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: any) => {
  238. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  239. createFormData[field.name] = field.default;
  240. });
  241. this.setState(
  242. {
  243. createFieldList: data,
  244. error: undefined,
  245. loading: false,
  246. createFormData,
  247. },
  248. this.onLoadSuccess
  249. );
  250. },
  251. error: this.errorHandler,
  252. });
  253. } else if (this.props.actionType === 'link') {
  254. this.api.request(this.getPluginLinkEndpoint(), {
  255. success: data => {
  256. const linkFormData = {};
  257. data.forEach((field: any) => {
  258. // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message
  259. linkFormData[field.name] = field.default;
  260. });
  261. this.setState(
  262. {
  263. linkFieldList: data,
  264. error: undefined,
  265. loading: false,
  266. linkFormData,
  267. },
  268. this.onLoadSuccess
  269. );
  270. },
  271. error: this.errorHandler,
  272. });
  273. }
  274. }
  275. onSuccess(data: any) {
  276. // TODO(ts): This needs a better approach. We splice in this attribute to trigger
  277. // a refetch in GroupDetails
  278. type StaleGroup = Group & {stale?: boolean};
  279. trackAnalytics('issue_details.external_issue_created', {
  280. organization: this.props.organization,
  281. ...getAnalyticsDataForGroup(this.props.group),
  282. external_issue_provider: this.props.plugin.slug,
  283. external_issue_type: 'plugin',
  284. });
  285. GroupStore.onUpdateSuccess('', [this.getGroup().id], {stale: true} as StaleGroup);
  286. this.props.onSuccess?.(data);
  287. }
  288. createIssue() {
  289. this.api.request(this.getPluginCreateEndpoint(), {
  290. data: this.state.createFormData,
  291. success: this.onSuccess,
  292. error: this.onSaveError.bind(this, (error: any) => {
  293. this.setError(error, t('There was an error creating the issue.'));
  294. }),
  295. complete: this.onSaveComplete,
  296. });
  297. }
  298. linkIssue() {
  299. this.api.request(this.getPluginLinkEndpoint(), {
  300. data: this.state.linkFormData,
  301. success: this.onSuccess,
  302. error: this.onSaveError.bind(this, (error: any) => {
  303. this.setError(error, t('There was an error linking the issue.'));
  304. }),
  305. complete: this.onSaveComplete,
  306. });
  307. }
  308. unlinkIssue() {
  309. this.api.request(this.getPluginUnlinkEndpoint(), {
  310. success: this.onSuccess,
  311. error: this.onSaveError.bind(this, (error: any) => {
  312. this.setError(error, t('There was an error unlinking the issue.'));
  313. }),
  314. complete: this.onSaveComplete,
  315. });
  316. }
  317. changeField(action: ActionType, name: string, value: any) {
  318. const formDataKey = this.getFormDataKey(action);
  319. // copy so we don't mutate
  320. const formData = {...this.state[formDataKey]};
  321. const fieldList = this.getFieldList();
  322. formData[name] = value;
  323. let callback = () => {};
  324. // only works with one impacted field
  325. const impactedField = fieldList.find(({depends}) => {
  326. if (!depends || !depends.length) {
  327. return false;
  328. }
  329. // must be dependent on the field we just set
  330. return depends.includes(name);
  331. });
  332. if (impactedField) {
  333. // if every dependent field is set, then search
  334. if (!impactedField.depends?.some(dependentField => !formData[dependentField])) {
  335. callback = () => this.loadOptionsForDependentField(impactedField);
  336. } else {
  337. // otherwise reset the options
  338. callback = () => this.resetOptionsOfDependentField(impactedField);
  339. }
  340. }
  341. this.setState(prevState => ({...prevState, [formDataKey]: formData}), callback);
  342. }
  343. renderForm(): React.ReactNode {
  344. switch (this.props.actionType) {
  345. case 'create':
  346. if (this.state.createFieldList) {
  347. return (
  348. <Form
  349. onSubmit={this.createIssue}
  350. submitLabel={t('Create Issue')}
  351. footerClass=""
  352. >
  353. {this.state.createFieldList.map(field => {
  354. if (field.has_autocomplete) {
  355. field = Object.assign(
  356. {
  357. url:
  358. '/api/0/issues/' +
  359. this.getGroup().id +
  360. '/plugins/' +
  361. this.props.plugin.slug +
  362. '/autocomplete',
  363. },
  364. field
  365. );
  366. }
  367. return (
  368. <div key={field.name}>
  369. {this.renderField({
  370. config: {...field, ...this.getInputProps(field)},
  371. formData: this.state.createFormData,
  372. onChange: this.changeField.bind(this, 'create', field.name),
  373. })}
  374. </div>
  375. );
  376. })}
  377. </Form>
  378. );
  379. }
  380. break;
  381. case 'link':
  382. if (this.state.linkFieldList) {
  383. return (
  384. <Form onSubmit={this.linkIssue} submitLabel={t('Link Issue')} footerClass="">
  385. {this.state.linkFieldList.map(field => {
  386. if (field.has_autocomplete) {
  387. field = Object.assign(
  388. {
  389. url:
  390. '/api/0/issues/' +
  391. this.getGroup().id +
  392. '/plugins/' +
  393. this.props.plugin.slug +
  394. '/autocomplete',
  395. },
  396. field
  397. );
  398. }
  399. return (
  400. <div key={field.name}>
  401. {this.renderField({
  402. config: {...field, ...this.getInputProps(field)},
  403. formData: this.state.linkFormData,
  404. onChange: this.changeField.bind(this, 'link', field.name),
  405. })}
  406. </div>
  407. );
  408. })}
  409. </Form>
  410. );
  411. }
  412. break;
  413. case 'unlink':
  414. return (
  415. <div>
  416. <p>{t('Are you sure you want to unlink this issue?')}</p>
  417. <button onClick={this.unlinkIssue} className="btn btn-danger">
  418. {t('Unlink Issue')}
  419. </button>
  420. </div>
  421. );
  422. default:
  423. return null;
  424. }
  425. return null;
  426. }
  427. getPluginConfigureUrl() {
  428. const org = this.getOrganization();
  429. const project = this.getProject();
  430. const plugin = this.props.plugin;
  431. return '/' + org.slug + '/' + project.slug + '/settings/plugins/' + plugin.slug;
  432. }
  433. renderError() {
  434. const error = this.state.error;
  435. if (!error) {
  436. return null;
  437. }
  438. if (error.error_type === 'auth') {
  439. let authUrl = error.auth_url;
  440. if (authUrl?.indexOf('?') === -1) {
  441. authUrl += '?next=' + encodeURIComponent(document.location.pathname);
  442. } else {
  443. authUrl += '&next=' + encodeURIComponent(document.location.pathname);
  444. }
  445. return (
  446. <div>
  447. <div className="alert alert-warning m-b-1">
  448. {'You need to associate an identity with ' +
  449. this.props.plugin.name +
  450. ' before you can create issues with this service.'}
  451. </div>
  452. <a className="btn btn-primary" href={authUrl}>
  453. Associate Identity
  454. </a>
  455. </div>
  456. );
  457. }
  458. if (error.error_type === 'config') {
  459. return (
  460. <div className="alert alert-block">
  461. {!error.has_auth_configured ? (
  462. <div>
  463. <p>
  464. {'Your server administrator will need to configure authentication with '}
  465. <strong>{this.props.plugin.name}</strong>
  466. {' before you can use this integration.'}
  467. </p>
  468. <p>The following settings must be configured:</p>
  469. <ul>
  470. {error.required_auth_settings?.map((setting, i) => (
  471. <li key={i}>
  472. <code>{setting}</code>
  473. </li>
  474. ))}
  475. </ul>
  476. </div>
  477. ) : (
  478. <p>
  479. You still need to{' '}
  480. <a href={this.getPluginConfigureUrl()}>configure this plugin</a> before you
  481. can use it.
  482. </p>
  483. )}
  484. </div>
  485. );
  486. }
  487. if (error.error_type === 'validation') {
  488. const errors: React.ReactElement[] = [];
  489. for (const name in error.errors) {
  490. errors.push(<p key={name}>{error.errors[name]}</p>);
  491. }
  492. return <div className="alert alert-error alert-block">{errors}</div>;
  493. }
  494. if (error.message) {
  495. return (
  496. <div className="alert alert-error alert-block">
  497. <p>{error.message}</p>
  498. </div>
  499. );
  500. }
  501. return <LoadingError />;
  502. }
  503. render() {
  504. if (this.state.state === FormState.LOADING) {
  505. return <LoadingIndicator />;
  506. }
  507. return (
  508. <div>
  509. {this.renderError()}
  510. {this.renderForm()}
  511. </div>
  512. );
  513. }
  514. }
  515. export default IssueActions;