Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 139 additions & 26 deletions static/app/plugins/components/issueActions.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<typeof GenericField>[0];

type Field = {
depends?: string[];
has_autocomplete?: boolean;
} & Parameters<typeof PluginComponentBase.prototype.renderField>[0]['config'];
} & Omit<GenericFieldProps, 'formState'>['config'];

type ActionType = 'link' | 'create' | 'unlink';
type FieldStateValue = (typeof FormState)[keyof typeof FormState];
Expand All @@ -45,6 +59,7 @@ type State = {
createFormData: Record<string, any>;
dependentFieldState: Record<string, FieldStateValue>;
linkFormData: Record<string, any>;
state: FormState;
unlinkFormData: Record<string, any>;
createFieldList?: Field[];
error?: {
Expand All @@ -58,30 +73,63 @@ type State = {
linkFieldList?: Field[];
loading?: boolean;
unlinkFieldList?: Field[];
} & PluginComponentBase['state'];
};

export class IssueActions extends PluginComponentBase<Props, State> {
export class IssueActions extends Component<Props, State> {
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));
this.onSuccess = this.onSaveSuccess.bind(this, this.onSuccess.bind(this));
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<State>;
}

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;
}
Expand Down Expand Up @@ -130,13 +178,6 @@ export class IssueActions extends PluginComponentBase<Props, State> {
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/`;
}
Expand All @@ -161,7 +202,6 @@ export class IssueActions extends PluginComponentBase<Props, State> {
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]])
);
Expand All @@ -187,11 +227,9 @@ export class IssueActions extends PluginComponentBase<Props, State> {
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;

Expand All @@ -210,7 +248,6 @@ export class IssueActions extends PluginComponentBase<Props, State> {
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:
Expand Down Expand Up @@ -251,10 +288,20 @@ export class IssueActions extends PluginComponentBase<Props, State> {
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) {
Expand All @@ -263,6 +310,68 @@ export class IssueActions extends PluginComponentBase<Props, State> {
});
}

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(), {
Expand Down Expand Up @@ -358,27 +467,22 @@ export class IssueActions extends PluginComponentBase<Props, State> {
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();

formData[name] = value;

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);
Expand All @@ -387,6 +491,15 @@ export class IssueActions extends PluginComponentBase<Props, State> {
this.setState(prevState => ({...prevState, [formDataKey]: formData}), callback);
}

renderField(props: Omit<GenericFieldProps, 'formState'>): React.ReactNode {
props = {...props};
const newProps = {
...props,
formState: this.state.state,
};
return <GenericField key={newProps.config?.name} {...newProps} />;
}

renderForm(): React.ReactNode {
switch (this.props.actionType) {
case 'create':
Expand Down
Loading
Loading