issueActions.tsx 16 KB

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