settings.tsx 7.8 KB

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