mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
563 lines
16 KiB
TypeScript
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;
|