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[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; dependentFieldState: Record; linkFormData: Record; unlinkFormData: Record; createFieldList?: Field[]; error?: { message: string; auth_url?: string; error_type?: string; errors?: Record; has_auth_configured?: boolean; required_auth_settings?: string[]; }; linkFieldList?: Field[]; loading?: boolean; unlinkFieldList?: Field[]; } & PluginComponentBase['state']; class IssueActions extends PluginComponentBase { 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 = { 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 && 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 (
{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 (
{this.renderField({ config: {...field, ...this.getInputProps(field)}, formData: this.state.createFormData, onChange: this.changeField.bind(this, 'create', field.name), })}
); })}
); } break; case 'link': if (this.state.linkFieldList) { return (
{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 (
{this.renderField({ config: {...field, ...this.getInputProps(field)}, formData: this.state.linkFormData, onChange: this.changeField.bind(this, 'link', field.name), })}
); })}
); } break; case 'unlink': return (

{t('Are you sure you want to unlink this issue?')}

); 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 (
{'You need to associate an identity with ' + this.props.plugin.name + ' before you can create issues with this service.'}
Associate Identity
); } if (error.error_type === 'config') { return (
{!error.has_auth_configured ? (

{'Your server administrator will need to configure authentication with '} {this.props.plugin.name} {' before you can use this integration.'}

The following settings must be configured:

    {error.required_auth_settings?.map((setting, i) => (
  • {setting}
  • ))}
) : (

You still need to{' '} configure this plugin before you can use it.

)}
); } if (error.error_type === 'validation') { const errors: React.ReactElement[] = []; for (const name in error.errors) { errors.push(

{error.errors[name]}

); } return
{errors}
; } if (error.message) { return (

{error.message}

); } return ; } render() { if (this.state.state === FormState.LOADING) { return ; } return (
{this.renderError()} {this.renderForm()}
); } } export default IssueActions;