Files
2024-02-15 17:01:50 +00:00

563 lines
16 KiB
TypeScript

import Form from 'sentry/components/deprecatedforms/form';
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, Organization, Plugin, Project} from 'sentry/types';
import {trackAnalytics} from 'sentry/utils/analytics';
import {getAnalyticsDataForGroup} from 'sentry/utils/events';
type Field = {
depends?: string[];
has_autocomplete?: boolean;
} & Parameters<typeof PluginComponentBase.prototype.renderField>[0]['config'];
type ActionType = 'link' | 'create' | 'unlink';
type FieldStateValue = (typeof FormState)[keyof typeof FormState];
type Props = {
actionType: ActionType;
group: Group;
organization: Organization;
plugin: Plugin & {
issue?: {
issue_id: string;
label: string;
url: string;
};
};
project: Project;
onError?: (data: any) => void;
onSuccess?: (data: any) => void;
};
type State = {
createFormData: Record<string, any>;
dependentFieldState: Record<string, FieldStateValue>;
linkFormData: Record<string, any>;
unlinkFormData: Record<string, any>;
createFieldList?: Field[];
error?: {
message: string;
auth_url?: string;
error_type?: string;
errors?: Record<string, string>;
has_auth_configured?: boolean;
required_auth_settings?: string[];
};
linkFieldList?: Field[];
loading?: boolean;
unlinkFieldList?: Field[];
} & PluginComponentBase['state'];
class IssueActions extends PluginComponentBase<Props, State> {
constructor(props: Props, context) {
super(props, context);
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,
createFormData: {},
linkFormData: {},
dependentFieldState: {},
};
}
getGroup() {
return this.props.group;
}
getProject() {
return this.props.project;
}
getOrganization() {
return this.props.organization;
}
getFieldListKey() {
switch (this.props.actionType) {
case 'link':
return 'linkFieldList';
case 'unlink':
return 'unlinkFieldList';
case 'create':
return 'createFieldList';
default:
throw new Error('Unexpeced action type');
}
}
getFormDataKey(actionType?: ActionType) {
switch (actionType || this.props.actionType) {
case 'link':
return 'linkFormData';
case 'unlink':
return 'unlinkFormData';
case 'create':
return 'createFormData';
default:
throw new Error('Unexpeced action type');
}
}
getFormData() {
const key = this.getFormDataKey();
return this.state[key] || {};
}
getFieldList() {
const key = this.getFieldListKey();
return this.state[key] || [];
}
componentDidMount() {
const plugin = this.props.plugin;
if (!plugin.issue && this.props.actionType !== 'unlink') {
this.fetchData();
}
}
getPluginCreateEndpoint() {
return (
'/issues/' + this.getGroup().id + '/plugins/' + this.props.plugin.slug + '/create/'
);
}
getPluginLinkEndpoint() {
return (
'/issues/' + this.getGroup().id + '/plugins/' + this.props.plugin.slug + '/link/'
);
}
getPluginUnlinkEndpoint() {
return (
'/issues/' + this.getGroup().id + '/plugins/' + this.props.plugin.slug + '/unlink/'
);
}
setDependentFieldState(fieldName, state) {
const dependentFieldState = {...this.state.dependentFieldState, [fieldName]: state};
this.setState({dependentFieldState});
}
loadOptionsForDependentField = async field => {
const formData = this.getFormData();
const groupId = this.getGroup().id;
const pluginSlug = this.props.plugin.slug;
const url = `/issues/${groupId}/plugins/${pluginSlug}/options/`;
// find the fields that this field is dependent on
const dependentFormValues = Object.fromEntries(
field.depends.map(fieldKey => [fieldKey, formData[fieldKey]])
);
const query = {
option_field: field.name,
...dependentFormValues,
};
try {
this.setDependentFieldState(field.name, FormState.LOADING);
const result = await this.api.requestPromise(url, {query});
this.updateOptionsOfDependentField(field, result[field.name]);
this.setDependentFieldState(field.name, FormState.READY);
} catch (err) {
this.setDependentFieldState(field.name, FormState.ERROR);
this.errorHandler(err);
}
};
updateOptionsOfDependentField = (field: Field, choices: Field['choices']) => {
const formListKey = this.getFieldListKey();
let fieldList = this.state[formListKey];
if (!fieldList) {
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;
this.setState(prevState => ({...prevState, [formListKey]: fieldList}));
};
resetOptionsOfDependentField = (field: Field) => {
this.updateOptionsOfDependentField(field, []);
const formDataKey = this.getFormDataKey();
const formData = {...this.state[formDataKey]};
formData[field.name] = '';
this.setState(prevState => ({...prevState, [formDataKey]: formData}));
this.setDependentFieldState(field.name, FormState.DISABLED);
};
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:
props.isLoading = true;
props.readonly = true;
break;
case FormState.DISABLED:
case FormState.ERROR:
props.readonly = true;
break;
default:
break;
}
}
return props;
}
setError(error, defaultMessage: string) {
let errorBody;
if (error.status === 400 && error.responseJSON) {
errorBody = error.responseJSON;
} else {
errorBody = {message: defaultMessage};
}
this.setState({error: errorBody});
}
errorHandler(error) {
const state: Pick<State, 'loading' | 'error'> = {
loading: false,
};
if (error.status === 400 && error.responseJSON) {
state.error = error.responseJSON;
} else {
state.error = {message: t('An unknown error occurred.')};
}
this.setState(state);
}
onLoadSuccess() {
super.onLoadSuccess();
// dependent fields need to be set to disabled upon loading
const fieldList = this.getFieldList();
fieldList.forEach(field => {
if (field.depends && field.depends.length > 0) {
this.setDependentFieldState(field.name, FormState.DISABLED);
}
});
}
fetchData() {
if (this.props.actionType === 'create') {
this.api.request(this.getPluginCreateEndpoint(), {
success: data => {
const createFormData = {};
data.forEach(field => {
createFormData[field.name] = field.default;
});
this.setState(
{
createFieldList: data,
error: undefined,
loading: false,
createFormData,
},
this.onLoadSuccess
);
},
error: this.errorHandler,
});
} else if (this.props.actionType === 'link') {
this.api.request(this.getPluginLinkEndpoint(), {
success: data => {
const linkFormData = {};
data.forEach(field => {
linkFormData[field.name] = field.default;
});
this.setState(
{
linkFieldList: data,
error: undefined,
loading: false,
linkFormData,
},
this.onLoadSuccess
);
},
error: this.errorHandler,
});
}
}
onSuccess(data) {
// TODO(ts): This needs a better approach. We splice in this attribute to trigger
// a refetch in GroupDetails
type StaleGroup = Group & {stale?: boolean};
trackAnalytics('issue_details.external_issue_created', {
organization: this.props.organization,
...getAnalyticsDataForGroup(this.props.group),
external_issue_provider: this.props.plugin.slug,
external_issue_type: 'plugin',
});
GroupStore.onUpdateSuccess('', [this.getGroup().id], {stale: true} as StaleGroup);
this.props.onSuccess?.(data);
}
createIssue() {
this.api.request(this.getPluginCreateEndpoint(), {
data: this.state.createFormData,
success: this.onSuccess,
error: this.onSaveError.bind(this, error => {
this.setError(error, t('There was an error creating the issue.'));
}),
complete: this.onSaveComplete,
});
}
linkIssue() {
this.api.request(this.getPluginLinkEndpoint(), {
data: this.state.linkFormData,
success: this.onSuccess,
error: this.onSaveError.bind(this, error => {
this.setError(error, t('There was an error linking the issue.'));
}),
complete: this.onSaveComplete,
});
}
unlinkIssue() {
this.api.request(this.getPluginUnlinkEndpoint(), {
success: this.onSuccess,
error: this.onSaveError.bind(this, error => {
this.setError(error, t('There was an error unlinking the issue.'));
}),
complete: this.onSaveComplete,
});
}
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 || !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])) {
callback = () => this.loadOptionsForDependentField(impactedField);
} else {
// otherwise reset the options
callback = () => this.resetOptionsOfDependentField(impactedField);
}
}
this.setState(prevState => ({...prevState, [formDataKey]: formData}), callback);
}
renderForm(): React.ReactNode {
switch (this.props.actionType) {
case 'create':
if (this.state.createFieldList) {
return (
<Form
onSubmit={this.createIssue}
submitLabel={t('Create Issue')}
footerClass=""
>
{this.state.createFieldList.map(field => {
if (field.has_autocomplete) {
field = Object.assign(
{
url:
'/api/0/issues/' +
this.getGroup().id +
'/plugins/' +
this.props.plugin.slug +
'/autocomplete',
},
field
);
}
return (
<div key={field.name}>
{this.renderField({
config: {...field, ...this.getInputProps(field)},
formData: this.state.createFormData,
onChange: this.changeField.bind(this, 'create', field.name),
})}
</div>
);
})}
</Form>
);
}
break;
case 'link':
if (this.state.linkFieldList) {
return (
<Form onSubmit={this.linkIssue} submitLabel={t('Link Issue')} footerClass="">
{this.state.linkFieldList.map(field => {
if (field.has_autocomplete) {
field = Object.assign(
{
url:
'/api/0/issues/' +
this.getGroup().id +
'/plugins/' +
this.props.plugin.slug +
'/autocomplete',
},
field
);
}
return (
<div key={field.name}>
{this.renderField({
config: {...field, ...this.getInputProps(field)},
formData: this.state.linkFormData,
onChange: this.changeField.bind(this, 'link', field.name),
})}
</div>
);
})}
</Form>
);
}
break;
case 'unlink':
return (
<div>
<p>{t('Are you sure you want to unlink this issue?')}</p>
<button onClick={this.unlinkIssue} className="btn btn-danger">
{t('Unlink Issue')}
</button>
</div>
);
default:
return null;
}
return null;
}
getPluginConfigureUrl() {
const org = this.getOrganization();
const project = this.getProject();
const plugin = this.props.plugin;
return '/' + org.slug + '/' + project.slug + '/settings/plugins/' + plugin.slug;
}
renderError() {
const error = this.state.error;
if (!error) {
return null;
}
if (error.error_type === 'auth') {
let authUrl = error.auth_url;
if (authUrl?.indexOf('?') === -1) {
authUrl += '?next=' + encodeURIComponent(document.location.pathname);
} else {
authUrl += '&next=' + encodeURIComponent(document.location.pathname);
}
return (
<div>
<div className="alert alert-warning m-b-1">
{'You need to associate an identity with ' +
this.props.plugin.name +
' before you can create issues with this service.'}
</div>
<a className="btn btn-primary" href={authUrl}>
Associate Identity
</a>
</div>
);
}
if (error.error_type === 'config') {
return (
<div className="alert alert-block">
{!error.has_auth_configured ? (
<div>
<p>
{'Your server administrator will need to configure authentication with '}
<strong>{this.props.plugin.name}</strong>
{' before you can use this integration.'}
</p>
<p>The following settings must be configured:</p>
<ul>
{error.required_auth_settings?.map((setting, i) => (
<li key={i}>
<code>{setting}</code>
</li>
))}
</ul>
</div>
) : (
<p>
You still need to{' '}
<a href={this.getPluginConfigureUrl()}>configure this plugin</a> before you
can use it.
</p>
)}
</div>
);
}
if (error.error_type === 'validation') {
const errors: React.ReactElement[] = [];
for (const name in error.errors) {
errors.push(<p key={name}>{error.errors[name]}</p>);
}
return <div className="alert alert-error alert-block">{errors}</div>;
}
if (error.message) {
return (
<div className="alert alert-error alert-block">
<p>{error.message}</p>
</div>
);
}
return <LoadingError />;
}
render() {
if (this.state.state === FormState.LOADING) {
return <LoadingIndicator />;
}
return (
<div>
{this.renderError()}
{this.renderForm()}
</div>
);
}
}
export default IssueActions;