settings.tsx 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233
  1. import styled from '@emotion/styled';
  2. import isEqual from 'lodash/isEqual';
  3. import Form from 'sentry/components/deprecatedforms/form';
  4. import FormState from 'sentry/components/forms/state';
  5. import LoadingIndicator from 'sentry/components/loadingIndicator';
  6. import {t, tct} from 'sentry/locale';
  7. import PluginComponentBase from 'sentry/plugins/pluginComponentBase';
  8. import {Organization, Plugin, Project} from 'sentry/types';
  9. import {parseRepo} from 'sentry/utils';
  10. import {IntegrationAnalyticsKey} from 'sentry/utils/analytics/integrations';
  11. import {trackIntegrationAnalytics} from 'sentry/utils/integrationUtil';
  12. type Props = {
  13. organization: Organization;
  14. plugin: Plugin;
  15. project: Project;
  16. } & PluginComponentBase['props'];
  17. type Field = Parameters<typeof PluginComponentBase.prototype.renderField>[0]['config'];
  18. type BackendField = Field & {defaultValue?: any; value?: any};
  19. type State = {
  20. errors: Record<string, any>;
  21. fieldList: Field[] | null;
  22. formData: Record<string, any>;
  23. initialData: Record<string, any> | null;
  24. rawData: Record<string, any>;
  25. wasConfiguredOnPageLoad: boolean;
  26. } & PluginComponentBase['state'];
  27. class PluginSettings<
  28. P extends Props = Props,
  29. S extends State = State
  30. > extends PluginComponentBase<P, S> {
  31. constructor(props: P, context: any) {
  32. super(props, context);
  33. Object.assign(this.state, {
  34. fieldList: null,
  35. initialData: null,
  36. formData: null,
  37. errors: {},
  38. rawData: {},
  39. // override default FormState.READY if api requests are
  40. // necessary to even load the form
  41. state: FormState.LOADING,
  42. wasConfiguredOnPageLoad: false,
  43. });
  44. }
  45. trackPluginEvent = (eventKey: IntegrationAnalyticsKey) => {
  46. trackIntegrationAnalytics(eventKey, {
  47. integration: this.props.plugin.id,
  48. integration_type: 'plugin',
  49. view: 'plugin_details',
  50. already_installed: this.state.wasConfiguredOnPageLoad,
  51. organization: this.props.organization,
  52. });
  53. };
  54. componentDidMount() {
  55. this.fetchData();
  56. }
  57. getPluginEndpoint() {
  58. const org = this.props.organization;
  59. const project = this.props.project;
  60. return `/projects/${org.slug}/${project.slug}/plugins/${this.props.plugin.id}/`;
  61. }
  62. changeField(name: string, value: any) {
  63. const formData: State['formData'] = this.state.formData;
  64. formData[name] = value;
  65. // upon changing a field, remove errors
  66. const errors = this.state.errors;
  67. delete errors[name];
  68. this.setState({formData, errors});
  69. }
  70. onSubmit() {
  71. if (!this.state.wasConfiguredOnPageLoad) {
  72. // Users cannot install plugins like other integrations but we need the events for the funnel
  73. // we will treat a user saving a plugin that wasn't already configured as an installation event
  74. this.trackPluginEvent('integrations.installation_start');
  75. }
  76. let repo = this.state.formData.repo;
  77. repo = repo && parseRepo(repo);
  78. const parsedFormData = {...this.state.formData, repo};
  79. this.api.request(this.getPluginEndpoint(), {
  80. data: parsedFormData,
  81. method: 'PUT',
  82. success: this.onSaveSuccess.bind(this, data => {
  83. const formData = {};
  84. const initialData = {};
  85. data.config.forEach(field => {
  86. formData[field.name] = field.value || field.defaultValue;
  87. initialData[field.name] = field.value;
  88. });
  89. this.setState({
  90. fieldList: data.config,
  91. formData,
  92. initialData,
  93. errors: {},
  94. });
  95. this.trackPluginEvent('integrations.config_saved');
  96. if (!this.state.wasConfiguredOnPageLoad) {
  97. this.trackPluginEvent('integrations.installation_complete');
  98. }
  99. }),
  100. error: this.onSaveError.bind(this, error => {
  101. this.setState({
  102. errors: (error.responseJSON || {}).errors || {},
  103. });
  104. }),
  105. complete: this.onSaveComplete,
  106. });
  107. }
  108. fetchData() {
  109. this.api.request(this.getPluginEndpoint(), {
  110. success: data => {
  111. if (!data.config) {
  112. this.setState(
  113. {
  114. rawData: data,
  115. },
  116. this.onLoadSuccess
  117. );
  118. return;
  119. }
  120. let wasConfiguredOnPageLoad = false;
  121. const formData = {};
  122. const initialData = {};
  123. data.config.forEach((field: BackendField) => {
  124. formData[field.name] = field.value || field.defaultValue;
  125. initialData[field.name] = field.value;
  126. // for simplicity sake, we will consider a plugin was configured if we have any value that is stored in the DB
  127. wasConfiguredOnPageLoad = wasConfiguredOnPageLoad || !!field.value;
  128. });
  129. this.setState(
  130. {
  131. fieldList: data.config,
  132. formData,
  133. initialData,
  134. wasConfiguredOnPageLoad,
  135. // call this here to prevent FormState.READY from being
  136. // set before fieldList is
  137. },
  138. this.onLoadSuccess
  139. );
  140. },
  141. error: this.onLoadError,
  142. });
  143. }
  144. render() {
  145. if (this.state.state === FormState.LOADING) {
  146. return <LoadingIndicator />;
  147. }
  148. const isSaving = this.state.state === FormState.SAVING;
  149. const hasChanges = !isEqual(this.state.initialData, this.state.formData);
  150. const data = this.state.rawData;
  151. if (data.config_error) {
  152. let authUrl = data.auth_url;
  153. if (authUrl.indexOf('?') === -1) {
  154. authUrl += '?next=' + encodeURIComponent(document.location.pathname);
  155. } else {
  156. authUrl += '&next=' + encodeURIComponent(document.location.pathname);
  157. }
  158. return (
  159. <div className="m-b-1">
  160. <div className="alert alert-warning m-b-1">{data.config_error}</div>
  161. <a className="btn btn-primary" href={authUrl}>
  162. {t('Associate Identity')}
  163. </a>
  164. </div>
  165. );
  166. }
  167. if (this.state.state === FormState.ERROR && !this.state.fieldList) {
  168. return (
  169. <div className="alert alert-error m-b-1">
  170. {tct('An unknown error occurred. Need help with this? [link:Contact support]', {
  171. link: <a href="https://sentry.io/support/" />,
  172. })}
  173. </div>
  174. );
  175. }
  176. const fieldList: State['fieldList'] = this.state.fieldList;
  177. if (!fieldList?.length) {
  178. return null;
  179. }
  180. return (
  181. <Form
  182. css={{width: '100%'}}
  183. onSubmit={this.onSubmit}
  184. submitDisabled={isSaving || !hasChanges}
  185. >
  186. <Flex>
  187. {this.state.errors.__all__ && (
  188. <div className="alert alert-block alert-error">
  189. <ul>
  190. <li>{this.state.errors.__all__}</li>
  191. </ul>
  192. </div>
  193. )}
  194. {this.state.fieldList?.map(f =>
  195. this.renderField({
  196. config: f,
  197. formData: this.state.formData,
  198. formErrors: this.state.errors,
  199. onChange: this.changeField.bind(this, f.name),
  200. })
  201. )}
  202. </Flex>
  203. </Form>
  204. );
  205. }
  206. }
  207. const Flex = styled('div')`
  208. display: flex;
  209. flex-direction: column;
  210. `;
  211. export default PluginSettings;