issueActions.tsx 16 KB

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