settings.tsx 7.1 KB

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