issueActions.tsx 16 KB

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