issueActions.tsx 16 KB

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