123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562 |
- 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;
|