settings.tsx 6.9 KB

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