diff --git a/static/app/plugins/components/issueActions.tsx b/static/app/plugins/components/issueActions.tsx index 5a74d6f2ee1bff..0b6b95ca229111 100644 --- a/static/app/plugins/components/issueActions.tsx +++ b/static/app/plugins/components/issueActions.tsx @@ -1,14 +1,22 @@ -import {Fragment} from 'react'; +import {Component, Fragment} from 'react'; +import isFunction from 'lodash/isFunction'; import {Alert} from '@sentry/scraps/alert'; import {Button, LinkButton} from '@sentry/scraps/button'; +import { + addErrorMessage, + addLoadingMessage, + addSuccessMessage, + clearIndicators, +} from 'sentry/actionCreators/indicator'; +import {Client} from 'sentry/api'; import {Form} from 'sentry/components/deprecatedforms/form'; +import {GenericField} from 'sentry/components/deprecatedforms/genericField'; import {FormState} from 'sentry/components/forms/state'; import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {t} from 'sentry/locale'; -import {PluginComponentBase} from 'sentry/plugins/pluginComponentBase'; import {GroupStore} from 'sentry/stores/groupStore'; import type {Group} from 'sentry/types/group'; import type {Plugin} from 'sentry/types/integrations'; @@ -17,10 +25,16 @@ import type {Project} from 'sentry/types/project'; import {trackAnalytics} from 'sentry/utils/analytics'; import {getAnalyticsDataForGroup} from 'sentry/utils/events'; +const callbackWithArgs = function (context: any, callback: any, ...args: any) { + return isFunction(callback) ? callback.bind(context, ...args) : undefined; +}; + +type GenericFieldProps = Parameters[0]; + type Field = { depends?: string[]; has_autocomplete?: boolean; -} & Parameters[0]['config']; +} & Omit['config']; type ActionType = 'link' | 'create' | 'unlink'; type FieldStateValue = (typeof FormState)[keyof typeof FormState]; @@ -45,6 +59,7 @@ type State = { createFormData: Record; dependentFieldState: Record; linkFormData: Record; + state: FormState; unlinkFormData: Record; createFieldList?: Field[]; error?: { @@ -58,12 +73,27 @@ type State = { linkFieldList?: Field[]; loading?: boolean; unlinkFieldList?: Field[]; -} & PluginComponentBase['state']; +}; -export class IssueActions extends PluginComponentBase { +export class IssueActions extends Component { constructor(props: Props) { super(props); + [ + 'onLoadSuccess', + 'onLoadError', + 'onSave', + 'onSaveSuccess', + 'onSaveError', + 'onSaveComplete', + 'renderField', + // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + ].map(method => (this[method] = this[method].bind(this))); + + if (this.fetchData) { + this.fetchData = this.onLoad.bind(this, this.fetchData.bind(this)); + } + this.createIssue = this.onSave.bind(this, this.createIssue.bind(this)); this.linkIssue = this.onSave.bind(this, this.linkIssue.bind(this)); this.unlinkIssue = this.onSave.bind(this, this.unlinkIssue.bind(this)); @@ -71,17 +101,35 @@ export class IssueActions extends PluginComponentBase { this.errorHandler = this.onLoadError.bind(this, this.errorHandler.bind(this)); this.state = { - ...this.state, - loading: ['link', 'create'].includes(this.props.actionType), state: ['link', 'create'].includes(this.props.actionType) ? FormState.LOADING : FormState.READY, + loading: ['link', 'create'].includes(this.props.actionType), createFormData: {}, linkFormData: {}, + unlinkFormData: {}, dependentFieldState: {}, - }; + } as Readonly; } + componentDidMount() { + const plugin = this.props.plugin; + if (!plugin.issue && this.props.actionType !== 'unlink') { + this.fetchData(); + } + } + + componentWillUnmount() { + this.api.clear(); + window.clearTimeout(this.successMessageTimeout); + window.clearTimeout(this.errorMessageTimeout); + } + + successMessageTimeout: number | undefined = undefined; + errorMessageTimeout: number | undefined = undefined; + + api = new Client(); + getGroup() { return this.props.group; } @@ -130,13 +178,6 @@ export class IssueActions extends PluginComponentBase { return this.state[key] || []; } - componentDidMount() { - const plugin = this.props.plugin; - if (!plugin.issue && this.props.actionType !== 'unlink') { - this.fetchData(); - } - } - getPluginCreateEndpoint() { return `/organizations/${this.getOrganization().slug}/issues/${this.getGroup().id}/plugins/${this.props.plugin.slug}/create/`; } @@ -161,7 +202,6 @@ export class IssueActions extends PluginComponentBase { const pluginSlug = this.props.plugin.slug; const url = `/organizations/${this.getOrganization().slug}/issues/${groupId}/plugins/${pluginSlug}/options/`; - // find the fields that this field is dependent on const dependentFormValues = Object.fromEntries( field.depends.map((fieldKey: any) => [fieldKey, formData[fieldKey]]) ); @@ -187,11 +227,9 @@ export class IssueActions extends PluginComponentBase { return; } - // find the location of the field in our list and replace it const indexOfField = fieldList.findIndex(({name}) => name === field.name); field = {...field, choices}; - // make a copy of the array to avoid mutation fieldList = fieldList.slice(); fieldList[indexOfField] = field; @@ -210,7 +248,6 @@ export class IssueActions extends PluginComponentBase { getInputProps(field: Field) { const props: {isLoading?: boolean; readonly?: boolean} = {}; - // special logic for fields that have dependencies if (field.depends && field.depends.length > 0) { switch (this.state.dependentFieldState[field.name]) { case FormState.LOADING: @@ -251,10 +288,20 @@ export class IssueActions extends PluginComponentBase { this.setState(state); } + onLoad(callback: any, ...args: any[]) { + this.setState( + { + state: FormState.LOADING, + }, + callbackWithArgs(this, callback, ...args) + ); + } + onLoadSuccess() { - super.onLoadSuccess(); + this.setState({ + state: FormState.READY, + }); - // dependent fields need to be set to disabled upon loading const fieldList = this.getFieldList(); fieldList.forEach(field => { if (field.depends && field.depends.length > 0) { @@ -263,6 +310,68 @@ export class IssueActions extends PluginComponentBase { }); } + onLoadError(callback: any, ...args: any[]) { + this.setState( + { + state: FormState.ERROR, + }, + callbackWithArgs(this, callback, ...args) + ); + addErrorMessage(t('An error occurred.')); + } + + onSave(callback: any, ...args: any[]) { + if (this.state.state === FormState.SAVING) { + return; + } + callback = callbackWithArgs(this, callback, ...args); + this.setState( + { + state: FormState.SAVING, + }, + () => { + addLoadingMessage(t('Saving changes…')); + callback?.(); + } + ); + } + + onSaveSuccess(callback: any, ...args: any[]) { + callback = callbackWithArgs(this, callback, ...args); + this.setState( + { + state: FormState.READY, + }, + () => callback?.() + ); + + window.clearTimeout(this.successMessageTimeout); + this.successMessageTimeout = window.setTimeout(() => { + addSuccessMessage(t('Success!')); + }, 0); + } + + onSaveError(callback: any, ...args: any[]) { + callback = callbackWithArgs(this, callback, ...args); + this.setState( + { + state: FormState.ERROR, + }, + () => callback?.() + ); + + window.clearTimeout(this.errorMessageTimeout); + this.errorMessageTimeout = window.setTimeout(() => { + addErrorMessage(t('Unable to save changes. Please try again.')); + }, 0); + } + + onSaveComplete(callback: any, ...args: any[]) { + clearIndicators(); + callback = callbackWithArgs(this, callback, ...args); + callback?.(); + } + fetchData() { if (this.props.actionType === 'create') { this.api.request(this.getPluginCreateEndpoint(), { @@ -358,7 +467,6 @@ export class IssueActions extends PluginComponentBase { changeField(action: ActionType, name: string, value: any) { const formDataKey = this.getFormDataKey(action); - // copy so we don't mutate const formData = {...this.state[formDataKey]}; const fieldList = this.getFieldList(); @@ -366,19 +474,15 @@ export class IssueActions extends PluginComponentBase { let callback = () => {}; - // only works with one impacted field const impactedField = fieldList.find(({depends}) => { if (!depends?.length) { return false; } - // must be dependent on the field we just set return depends.includes(name); }); if (impactedField) { - // if every dependent field is set, then search if (impactedField.depends?.some(dependentField => !formData[dependentField])) { - // otherwise reset the options callback = () => this.resetOptionsOfDependentField(impactedField); } else { callback = () => this.loadOptionsForDependentField(impactedField); @@ -387,6 +491,15 @@ export class IssueActions extends PluginComponentBase { this.setState(prevState => ({...prevState, [formDataKey]: formData}), callback); } + renderField(props: Omit): React.ReactNode { + props = {...props}; + const newProps = { + ...props, + formState: this.state.state, + }; + return ; + } + renderForm(): React.ReactNode { switch (this.props.actionType) { case 'create': diff --git a/static/app/plugins/components/settings.tsx b/static/app/plugins/components/settings.tsx index fd854e0bb94ed0..84626d1289548a 100644 --- a/static/app/plugins/components/settings.tsx +++ b/static/app/plugins/components/settings.tsx @@ -1,28 +1,43 @@ +import {Component} from 'react'; import {css} from '@emotion/react'; import isEqual from 'lodash/isEqual'; +import isFunction from 'lodash/isFunction'; import {Alert} from '@sentry/scraps/alert'; import {LinkButton} from '@sentry/scraps/button'; import {Stack} from '@sentry/scraps/layout'; +import { + addErrorMessage, + addLoadingMessage, + addSuccessMessage, + clearIndicators, +} from 'sentry/actionCreators/indicator'; +import {Client} from 'sentry/api'; import {Form} from 'sentry/components/deprecatedforms/form'; +import {GenericField} from 'sentry/components/deprecatedforms/genericField'; import {FormState} from 'sentry/components/forms/state'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {t, tct} from 'sentry/locale'; -import {PluginComponentBase} from 'sentry/plugins/pluginComponentBase'; import type {Plugin} from 'sentry/types/integrations'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {parseRepo} from 'sentry/utils/git/parseRepo'; import {isScmPlugin, trackIntegrationAnalytics} from 'sentry/utils/integrationUtil'; +const callbackWithArgs = function (context: any, callback: any, ...args: any) { + return isFunction(callback) ? callback.bind(context, ...args) : undefined; +}; + +type GenericFieldProps = Parameters[0]; + type Props = { organization: Organization; plugin: Plugin; project: Project; -} & PluginComponentBase['props']; +}; -type Field = Parameters[0]['config']; +type Field = GenericFieldProps['config']; type BackendField = Field & {defaultValue?: any; value?: any}; @@ -32,29 +47,61 @@ type State = { formData: Record; initialData: Record | null; rawData: Record; + state: FormState; wasConfiguredOnPageLoad: boolean; -} & PluginComponentBase['state']; +}; export class PluginSettings< P extends Props = Props, S extends State = State, -> extends PluginComponentBase { +> extends Component { constructor(props: P) { super(props); - Object.assign(this.state, { + [ + 'onLoadSuccess', + 'onLoadError', + 'onSave', + 'onSaveSuccess', + 'onSaveError', + 'onSaveComplete', + 'renderField', + // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message + ].map(method => (this[method] = this[method].bind(this))); + + if (this.fetchData) { + this.fetchData = this.onLoad.bind(this, this.fetchData.bind(this)); + } + if (this.onSubmit) { + this.onSubmit = this.onSave.bind(this, this.onSubmit.bind(this)); + } + + this.state = { + state: FormState.LOADING, fieldList: null, initialData: null, formData: null, errors: {}, rawData: {}, - // override default FormState.READY if api requests are - // necessary to even load the form - state: FormState.LOADING, wasConfiguredOnPageLoad: false, - }); + } as unknown as Readonly; } + componentDidMount() { + this.fetchData(); + } + + componentWillUnmount() { + this.api.clear(); + window.clearTimeout(this.successMessageTimeout); + window.clearTimeout(this.errorMessageTimeout); + } + + successMessageTimeout: number | undefined = undefined; + errorMessageTimeout: number | undefined = undefined; + + api = new Client(); + trackPluginEvent = ( eventKey: | 'integrations.installation_start' @@ -78,10 +125,6 @@ export class PluginSettings< }); }; - componentDidMount() { - this.fetchData(); - } - getPluginEndpoint() { const org = this.props.organization; const project = this.props.project; @@ -92,16 +135,90 @@ export class PluginSettings< // eslint-disable-next-line @sentry/no-unnecessary-type-annotation const formData: State['formData'] = this.state.formData; formData[name] = value; - // upon changing a field, remove errors const errors = this.state.errors; delete errors[name]; this.setState({formData, errors}); } + onLoad(callback: any, ...args: any[]) { + this.setState( + { + state: FormState.LOADING, + }, + callbackWithArgs(this, callback, ...args) + ); + } + + onLoadSuccess() { + this.setState({ + state: FormState.READY, + }); + } + + onLoadError(callback: any, ...args: any[]) { + this.setState( + { + state: FormState.ERROR, + }, + callbackWithArgs(this, callback, ...args) + ); + addErrorMessage(t('An error occurred.')); + } + + onSave(callback: any, ...args: any[]) { + if (this.state.state === FormState.SAVING) { + return; + } + callback = callbackWithArgs(this, callback, ...args); + this.setState( + { + state: FormState.SAVING, + }, + () => { + addLoadingMessage(t('Saving changes…')); + callback?.(); + } + ); + } + + onSaveSuccess(callback: any, ...args: any[]) { + callback = callbackWithArgs(this, callback, ...args); + this.setState( + { + state: FormState.READY, + }, + () => callback?.() + ); + + window.clearTimeout(this.successMessageTimeout); + this.successMessageTimeout = window.setTimeout(() => { + addSuccessMessage(t('Success!')); + }, 0); + } + + onSaveError(callback: any, ...args: any[]) { + callback = callbackWithArgs(this, callback, ...args); + this.setState( + { + state: FormState.ERROR, + }, + () => callback?.() + ); + + window.clearTimeout(this.errorMessageTimeout); + this.errorMessageTimeout = window.setTimeout(() => { + addErrorMessage(t('Unable to save changes. Please try again.')); + }, 0); + } + + onSaveComplete(callback: any, ...args: any[]) { + clearIndicators(); + callback = callbackWithArgs(this, callback, ...args); + callback?.(); + } + onSubmit() { if (!this.state.wasConfiguredOnPageLoad) { - // Users cannot install plugins like other integrations but we need the events for the funnel - // we will treat a user saving a plugin that wasn't already configured as an installation event this.trackPluginEvent('integrations.installation_start'); } @@ -161,7 +278,6 @@ export class PluginSettings< formData[field.name] = field.value || field.defaultValue; // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message initialData[field.name] = field.value; - // for simplicity sake, we will consider a plugin was configured if we have any value that is stored in the DB wasConfiguredOnPageLoad = wasConfiguredOnPageLoad || !!field.value; }); this.setState( @@ -170,8 +286,6 @@ export class PluginSettings< formData, initialData, wasConfiguredOnPageLoad, - // call this here to prevent FormState.READY from being - // set before fieldList is }, this.onLoadSuccess ); @@ -180,6 +294,15 @@ export class PluginSettings< }); } + renderField(props: Omit): React.ReactNode { + props = {...props}; + const newProps = { + ...props, + formState: this.state.state, + }; + return ; + } + render() { if (this.state.state === FormState.LOADING) { return ; diff --git a/static/app/plugins/pluginComponentBase.tsx b/static/app/plugins/pluginComponentBase.tsx deleted file mode 100644 index abd254eee5711e..00000000000000 --- a/static/app/plugins/pluginComponentBase.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import {Component} from 'react'; -import isFunction from 'lodash/isFunction'; - -import { - addErrorMessage, - addLoadingMessage, - addSuccessMessage, - clearIndicators, -} from 'sentry/actionCreators/indicator'; -import {Client} from 'sentry/api'; -import {GenericField} from 'sentry/components/deprecatedforms/genericField'; -import {FormState} from 'sentry/components/forms/state'; -import {t} from 'sentry/locale'; - -const callbackWithArgs = function (context: any, callback: any, ...args: any) { - return isFunction(callback) ? callback.bind(context, ...args) : undefined; -}; - -type GenericFieldProps = Parameters[0]; - -type Props = Record; - -type State = {state: FormState}; - -export abstract class PluginComponentBase< - P extends Props = Props, - S extends State = State, -> extends Component { - constructor(props: P) { - super(props); - - [ - 'onLoadSuccess', - 'onLoadError', - 'onSave', - 'onSaveSuccess', - 'onSaveError', - 'onSaveComplete', - 'renderField', - // @ts-expect-error TS(7053): Element implicitly has an 'any' type because expre... Remove this comment to see the full error message - ].map(method => (this[method] = this[method].bind(this))); - - if (this.fetchData) { - this.fetchData = this.onLoad.bind(this, this.fetchData.bind(this)); - } - if (this.onSubmit) { - this.onSubmit = this.onSave.bind(this, this.onSubmit.bind(this)); - } - this.state = { - state: FormState.READY, - } as Readonly; - } - - componentWillUnmount() { - this.api.clear(); - window.clearTimeout(this.successMessageTimeout); - window.clearTimeout(this.errorMessageTimeout); - } - - successMessageTimeout: number | undefined = undefined; - errorMessageTimeout: number | undefined = undefined; - - api = new Client(); - - fetchData() { - // Allow children to implement this - } - - onSubmit() { - // Allow children to implement this - } - - onLoad(callback: any, ...args: any[]) { - this.setState( - { - state: FormState.LOADING, - }, - callbackWithArgs(this, callback, ...args) - ); - } - - onLoadSuccess() { - this.setState({ - state: FormState.READY, - }); - } - - onLoadError(callback: any, ...args: any[]) { - this.setState( - { - state: FormState.ERROR, - }, - callbackWithArgs(this, callback, ...args) - ); - addErrorMessage(t('An error occurred.')); - } - - onSave(callback: any, ...args: any[]) { - if (this.state.state === FormState.SAVING) { - return; - } - callback = callbackWithArgs(this, callback, ...args); - this.setState( - { - state: FormState.SAVING, - }, - () => { - addLoadingMessage(t('Saving changes\u2026')); - callback?.(); - } - ); - } - - onSaveSuccess(callback: any, ...args: any[]) { - callback = callbackWithArgs(this, callback, ...args); - this.setState( - { - state: FormState.READY, - }, - () => callback?.() - ); - - window.clearTimeout(this.successMessageTimeout); - this.successMessageTimeout = window.setTimeout(() => { - addSuccessMessage(t('Success!')); - }, 0); - } - - onSaveError(callback: any, ...args: any[]) { - callback = callbackWithArgs(this, callback, ...args); - this.setState( - { - state: FormState.ERROR, - }, - () => callback?.() - ); - - window.clearTimeout(this.errorMessageTimeout); - this.errorMessageTimeout = window.setTimeout(() => { - addErrorMessage(t('Unable to save changes. Please try again.')); - }, 0); - } - - onSaveComplete(callback: any, ...args: any[]) { - clearIndicators(); - callback = callbackWithArgs(this, callback, ...args); - callback?.(); - } - - renderField(props: Omit): React.ReactNode { - props = {...props}; - const newProps = { - ...props, - formState: this.state.state, - }; - return ; - } -}