settings.tsx 7.0 KB

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