123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377 |
- import {Fragment} from 'react';
- import debounce from 'lodash/debounce';
- import * as qs from 'query-string';
- import {ModalRenderProps} from 'sentry/actionCreators/modal';
- import AsyncComponent from 'sentry/components/asyncComponent';
- import FieldFromConfig from 'sentry/components/forms/fieldFromConfig';
- import Form, {FormProps} from 'sentry/components/forms/form';
- import FormModel, {FieldValue} from 'sentry/components/forms/model';
- import QuestionTooltip from 'sentry/components/questionTooltip';
- import {tct} from 'sentry/locale';
- import {
- Choices,
- IntegrationIssueConfig,
- IssueConfigField,
- SelectValue,
- } from 'sentry/types';
- import {FormField} from 'sentry/views/alerts/rules/issue/ruleNode';
- export type ExternalIssueAction = 'create' | 'link';
- export type ExternalIssueFormErrors = {[key: string]: React.ReactNode};
- type Props = ModalRenderProps & AsyncComponent['props'];
- type State = {
- action: ExternalIssueAction;
- /**
- * Object of fields where `updatesFrom` is true, by field name. Derived from
- * `integrationDetails` when it loads. Null until set.
- */
- dynamicFieldValues: {[key: string]: FieldValue | null} | null;
- /**
- * Cache of options fetched for async fields.
- */
- fetchedFieldOptionsCache: Record<string, Choices>;
- /**
- * Fetched via endpoint, null until set.
- */
- integrationDetails: IntegrationIssueConfig | null;
- } & AsyncComponent['state'];
- const DEBOUNCE_MS = 200;
- /**
- * @abstract
- */
- export default class AbstractExternalIssueForm<
- P extends Props = Props,
- S extends State = State
- > extends AsyncComponent<P, S> {
- shouldRenderBadRequests = true;
- model = new FormModel();
- getDefaultState(): State {
- return {
- ...super.getDefaultState(),
- action: 'create',
- dynamicFieldValues: null,
- fetchedFieldOptionsCache: {},
- integrationDetails: null,
- };
- }
- refetchConfig = () => {
- const {action, dynamicFieldValues} = this.state;
- const query = {action, ...dynamicFieldValues};
- const endpoint = this.getEndPointString();
- this.api.request(endpoint, {
- method: 'GET',
- query,
- success: (data, _, resp) => {
- this.handleRequestSuccess({stateKey: 'integrationDetails', data, resp}, true);
- },
- error: error => {
- this.handleError(error, ['integrationDetails', endpoint, null, null]);
- },
- });
- };
- getConfigName = (): 'createIssueConfig' | 'linkIssueConfig' => {
- // Explicitly returning a non-interpolated string for clarity.
- const {action} = this.state;
- switch (action) {
- case 'create':
- return 'createIssueConfig';
- case 'link':
- return 'linkIssueConfig';
- default:
- throw new Error('illegal action');
- }
- };
- /**
- * Convert IntegrationIssueConfig to an object that maps field names to the
- * values of fields where `updatesFrom` is true. This function prefers to read
- * configs from its parameters and otherwise falls back to reading from state.
- * @param integrationDetailsParam
- * @returns Object of field names to values.
- */
- getDynamicFields = (
- integrationDetailsParam?: IntegrationIssueConfig
- ): {[key: string]: FieldValue | null} => {
- const {integrationDetails: integrationDetailsFromState} = this.state;
- const integrationDetails = integrationDetailsParam || integrationDetailsFromState;
- const config = (integrationDetails || {})[this.getConfigName()];
- return Object.fromEntries(
- (config || [])
- .filter((field: IssueConfigField) => field.updatesForm)
- .map((field: IssueConfigField) => [field.name, field.default || null])
- );
- };
- onRequestSuccess = ({stateKey, data}) => {
- if (stateKey === 'integrationDetails') {
- this.handleReceiveIntegrationDetails(data);
- this.setState({
- dynamicFieldValues: this.getDynamicFields(data),
- });
- }
- };
- /**
- * If this field should updateForm, updateForm. Otherwise, do nothing.
- */
- onFieldChange = (fieldName: string, value: FieldValue) => {
- const {dynamicFieldValues} = this.state;
- const dynamicFields = this.getDynamicFields();
- if (dynamicFields.hasOwnProperty(fieldName) && dynamicFieldValues) {
- dynamicFieldValues[fieldName] = value;
- this.setState(
- {
- dynamicFieldValues,
- reloading: true,
- error: false,
- remainingRequests: 1,
- },
- this.refetchConfig
- );
- }
- };
- /**
- * For fields with dynamic fields, cache the fetched choices.
- */
- updateFetchedFieldOptionsCache = (
- field: IssueConfigField,
- result: SelectValue<string | number>[]
- ): void => {
- const {fetchedFieldOptionsCache} = this.state;
- this.setState({
- fetchedFieldOptionsCache: {
- ...fetchedFieldOptionsCache,
- [field.name]: result.map(obj => [obj.value, obj.label]),
- },
- });
- };
- /**
- * Ensures current result from Async select fields is never discarded. Without this method,
- * searching in an async select field without selecting one of the returned choices will
- * result in a value saved to the form, and no associated label; appearing empty.
- * @param field The field being examined
- * @param result The result from it's asynchronous query
- * @returns The result with a tooltip attached to the current option
- */
- ensureCurrentOption = (
- field: IssueConfigField,
- result: SelectValue<string | number>[]
- ): SelectValue<string | number>[] => {
- const currentOption = this.getDefaultOptions(field).find(
- option => option.value === this.model.getValue(field.name)
- );
- if (!currentOption) {
- return result;
- }
- if (typeof currentOption.label === 'string') {
- currentOption.label = (
- <Fragment>
- <QuestionTooltip
- title={tct('This is your current [label].', {
- label: field.label,
- })}
- size="xs"
- />{' '}
- {currentOption.label}
- </Fragment>
- );
- }
- const currentOptionResultIndex = result.findIndex(
- obj => obj.value === currentOption?.value
- );
- // Has a selected option, and it is in API results
- if (currentOptionResultIndex >= 0) {
- const newResult = result;
- newResult[currentOptionResultIndex] = currentOption;
- return newResult;
- }
- // Has a selected option, and it is not in API results
- return [...result, currentOption];
- };
- /**
- * Get the list of options for a field via debounced API call. For example,
- * the list of users that match the input string. The Promise rejects if there
- * are any errors.
- */
- getOptions = (field: IssueConfigField, input: string) =>
- new Promise((resolve, reject) => {
- if (!input) {
- return resolve(this.getDefaultOptions(field));
- }
- return this.debouncedOptionLoad(field, input, (err, result) => {
- if (err) {
- reject(err);
- } else {
- result = this.ensureCurrentOption(field, result);
- this.updateFetchedFieldOptionsCache(field, result);
- resolve(result);
- }
- });
- });
- debouncedOptionLoad = debounce(
- async (
- field: IssueConfigField,
- input: string,
- cb: (err: Error | null, result?: any) => void
- ) => {
- const {dynamicFieldValues} = this.state;
- const query = qs.stringify({
- ...dynamicFieldValues,
- field: field.name,
- query: input,
- });
- const url = field.url || '';
- const separator = url.includes('?') ? '&' : '?';
- // We can't use the API client here since the URL is not scoped under the
- // API endpoints (which the client prefixes)
- try {
- const response = await fetch(url + separator + query);
- cb(null, response.ok ? await response.json() : []);
- } catch (err) {
- cb(err);
- }
- },
- DEBOUNCE_MS,
- {trailing: true}
- );
- getDefaultOptions = (field: IssueConfigField) => {
- const choices =
- (field.choices as Array<[number | string, number | string | React.ReactElement]>) ||
- [];
- return choices.map(([value, label]) => ({value, label}));
- };
- /**
- * If this field is an async select (field.url is not null), add async props.
- */
- getFieldProps = (field: IssueConfigField) =>
- field.url
- ? {
- async: true,
- autoload: true,
- cache: false,
- loadOptions: (input: string) => this.getOptions(field, input),
- defaultOptions: this.getDefaultOptions(field),
- onBlurResetsInput: false,
- onCloseResetsInput: false,
- onSelectResetsInput: false,
- }
- : {};
- // Abstract methods.
- handleReceiveIntegrationDetails = (_data: any) => {
- // Do nothing.
- };
- getEndPointString(): string {
- throw new Error("Method 'getEndPointString()' must be implemented.");
- }
- renderNavTabs = (): React.ReactNode => null;
- renderBodyText = (): React.ReactNode => null;
- getTitle = () => tct('Issue Link Settings', {});
- getFormProps = (): FormProps => {
- throw new Error("Method 'getFormProps()' must be implemented.");
- };
- getDefaultFormProps = (): FormProps => {
- return {
- footerClass: 'modal-footer',
- onFieldChange: this.onFieldChange,
- submitDisabled: this.state.reloading,
- model: this.model,
- // Other form props implemented by child classes.
- };
- };
- getCleanedFields = (): IssueConfigField[] => {
- const {fetchedFieldOptionsCache, integrationDetails} = this.state;
- const configsFromAPI = (integrationDetails || {})[this.getConfigName()];
- return (configsFromAPI || []).map(field => {
- const fieldCopy = {...field};
- // Overwrite choices from cache.
- if (fetchedFieldOptionsCache?.hasOwnProperty(field.name)) {
- fieldCopy.choices = fetchedFieldOptionsCache[field.name];
- }
- return fieldCopy;
- });
- };
- renderComponent() {
- return this.state.error
- ? this.renderError(new Error('Unable to load all required endpoints'))
- : this.renderBody();
- }
- renderForm = (
- formFields?: IssueConfigField[],
- errors: ExternalIssueFormErrors = {}
- ) => {
- const initialData: {[key: string]: any} = (formFields || []).reduce(
- (accumulator, field: FormField) => {
- accumulator[field.name] =
- // Passing an empty array breaks MultiSelect.
- field.multiple && field.default.length === 0 ? '' : field.default;
- return accumulator;
- },
- {}
- );
- const {Header, Body} = this.props as ModalRenderProps;
- return (
- <Fragment>
- <Header closeButton>{this.getTitle()}</Header>
- {this.renderNavTabs()}
- <Body>
- {this.shouldRenderLoading ? (
- this.renderLoading()
- ) : (
- <Fragment>
- {this.renderBodyText()}
- <Form initialData={initialData} {...this.getFormProps()}>
- {(formFields || [])
- .filter((field: FormField) => field.hasOwnProperty('name'))
- .map(fields => ({
- ...fields,
- noOptionsMessage: () => 'No options. Type to search.',
- }))
- .map((field, i) => {
- return (
- <Fragment key={`${field.name}-${i}`}>
- <FieldFromConfig
- disabled={this.state.reloading}
- field={field}
- flexibleControlStateSize
- inline={false}
- stacked
- {...this.getFieldProps(field)}
- />
- {errors[field.name] && errors[field.name]}
- </Fragment>
- );
- })}
- </Form>
- </Fragment>
- )}
- </Body>
- </Fragment>
- );
- };
- }
|