123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436 |
- import {Component} from 'react';
- import {createFilter} from 'react-select';
- import debounce from 'lodash/debounce';
- import {addErrorMessage} from 'sentry/actionCreators/indicator';
- import {Client} from 'sentry/api';
- import {GeneralSelectValue} from 'sentry/components/forms/controls/selectControl';
- import FieldFromConfig from 'sentry/components/forms/fieldFromConfig';
- import Form from 'sentry/components/forms/form';
- import FormModel from 'sentry/components/forms/model';
- import {Field, FieldValue} from 'sentry/components/forms/types';
- import {t} from 'sentry/locale';
- import {replaceAtArrayIndex} from 'sentry/utils/replaceAtArrayIndex';
- import withApi from 'sentry/utils/withApi';
- const hasValue = value => !!value || value === 0;
- export type FieldFromSchema = Omit<Field, 'choices' | 'type'> & {
- type: 'select' | 'textarea' | 'text';
- async?: boolean;
- choices?: Array<[any, string]>;
- default?: 'issue.title' | 'issue.description';
- depends_on?: string[];
- uri?: string;
- };
- export type SchemaFormConfig = {
- description: string | null;
- uri: string;
- optional_fields?: FieldFromSchema[];
- required_fields?: FieldFromSchema[];
- };
- type SentryAppSetting = {
- name: string;
- value: any;
- label?: string;
- };
- type State = Omit<SchemaFormConfig, 'uri' | 'description'> & {
- optionsByField: Map<string, Array<{label: string; value: any}>>;
- selectedOptions: {[name: string]: GeneralSelectValue};
- };
- type Props = {
- action: 'create' | 'link';
- api: Client;
- appName: string;
- config: SchemaFormConfig;
- element: 'issue-link' | 'alert-rule-action';
- onSubmitSuccess: Function;
- sentryAppInstallationUuid: string;
-
- extraFields?: {[key: string]: any};
-
- extraRequestBody?: {[key: string]: any};
-
- getFieldDefault?: (field: FieldFromSchema) => string;
-
- resetValues?: {
- [key: string]: any;
- settings?: SentryAppSetting[];
- };
- };
- export class SentryAppExternalForm extends Component<Props, State> {
- state: State = {optionsByField: new Map(), selectedOptions: {}};
- componentDidMount() {
- this.resetStateFromProps();
- }
- componentDidUpdate(prevProps: Props) {
- if (prevProps.action !== this.props.action) {
- this.model.reset();
- this.resetStateFromProps();
- }
- }
- model = new FormModel();
-
- resetStateFromProps() {
- const {config, action, extraFields, element} = this.props;
- this.setState({
- required_fields: config.required_fields,
- optional_fields: config.optional_fields,
- });
-
-
- if (element === 'alert-rule-action') {
- const defaultResetValues = (this.props.resetValues || {}).settings || [];
- const initialData = defaultResetValues.reduce((acc, curr) => {
- acc[curr.name] = curr.value;
- return acc;
- }, {});
- this.model.setInitialData({...initialData});
- } else {
- this.model.setInitialData({
- ...extraFields,
-
- action,
- uri: config.uri,
- });
- }
- }
- onSubmitError = () => {
- const {action, appName} = this.props;
- addErrorMessage(t('Unable to %s %s %s.', action, appName, this.getElementText()));
- };
- getOptions = (field: FieldFromSchema, input: string) =>
- new Promise(resolve => {
- this.debouncedOptionLoad(field, input, resolve);
- });
- getElementText = () => {
- const {element} = this.props;
- switch (element) {
- case 'issue-link':
- return 'issue';
- case 'alert-rule-action':
- return 'alert';
- default:
- return 'connection';
- }
- };
- getDefaultOptions = (field: FieldFromSchema) => {
- const savedOption = ((this.props.resetValues || {}).settings || []).find(
- value => value.name === field.name
- );
- const currentOptions = (field.choices || []).map(([value, label]) => ({
- value,
- label,
- }));
- const shouldAddSavedOption =
-
-
- savedOption?.value &&
- savedOption?.label &&
-
- !currentOptions.some(option => option.value === savedOption?.value);
- return shouldAddSavedOption
- ? [{value: savedOption.value, label: savedOption.label}, ...currentOptions]
- : currentOptions;
- };
- getDefaultFieldValue = (field: FieldFromSchema) => {
-
- const {resetValues, getFieldDefault} = this.props;
- let defaultValue = field?.defaultValue;
-
- if (field.default && getFieldDefault) {
- defaultValue = getFieldDefault(field);
- }
- const reset = ((resetValues || {}).settings || []).find(
- value => value.name === field.name
- );
- if (reset) {
- defaultValue = reset.value;
- }
- return defaultValue;
- };
- debouncedOptionLoad = debounce(
-
-
- async (field: FieldFromSchema, input, resolve) => {
- const choices = await this.makeExternalRequest(field, input);
- const options = choices.map(([value, label]) => ({value, label}));
- const optionsByField = new Map(this.state.optionsByField);
- optionsByField.set(field.name, options);
- this.setState({
- optionsByField,
- });
- return resolve(options);
- },
- 200,
- {trailing: true}
- );
- makeExternalRequest = async (field: FieldFromSchema, input: FieldValue) => {
- const {extraRequestBody = {}, sentryAppInstallationUuid} = this.props;
- const query: {[key: string]: any} = {
- ...extraRequestBody,
- uri: field.uri,
- query: input,
- };
- if (field.depends_on) {
- const dependentData = field.depends_on.reduce((accum, dependentField: string) => {
- accum[dependentField] = this.model.getValue(dependentField);
- return accum;
- }, {});
-
- query.dependentData = JSON.stringify(dependentData);
- }
- const {choices} = await this.props.api.requestPromise(
- `/sentry-app-installations/${sentryAppInstallationUuid}/external-requests/`,
- {query}
- );
- return choices || [];
- };
-
- handleFieldChange = async (id: string) => {
- const config = this.state;
- let requiredFields = config.required_fields || [];
- let optionalFields = config.optional_fields || [];
- const fieldList: FieldFromSchema[] = requiredFields.concat(optionalFields);
-
- const impactedFields = fieldList.filter(({depends_on}) => {
- if (!depends_on) {
- return false;
- }
-
- return depends_on.includes(id);
- });
-
- const choiceArray = await Promise.all(
- impactedFields.map(field => {
-
- this.model.setValue(field.name || '', '', {quiet: true});
- return this.makeExternalRequest(field, '');
- })
- );
- this.setState(state => {
-
- requiredFields = state.required_fields || [];
- optionalFields = state.optional_fields || [];
-
- impactedFields.forEach((impactedField, i) => {
- const choices = choiceArray[i];
- const requiredIndex = requiredFields.indexOf(impactedField);
- const optionalIndex = optionalFields.indexOf(impactedField);
- const updatedField = {...impactedField, choices};
-
- if (requiredIndex > -1) {
- requiredFields = replaceAtArrayIndex(
- requiredFields,
- requiredIndex,
- updatedField
- );
- } else if (optionalIndex > -1) {
- optionalFields = replaceAtArrayIndex(
- optionalFields,
- optionalIndex,
- updatedField
- );
- }
- });
- return {
- required_fields: requiredFields,
- optional_fields: optionalFields,
- };
- });
- };
- createPreserveOptionFunction = (name: string) => (option, _event) => {
- this.setState({
- selectedOptions: {
- ...this.state.selectedOptions,
- [name]: option,
- },
- });
- };
- renderField = (field: FieldFromSchema, required: boolean) => {
-
-
- let fieldToPass: Field = {
- ...field,
- inline: false,
- stacked: true,
- flexibleControlStateSize: true,
- required,
- };
- if (field?.uri && field?.async) {
- fieldToPass.type = 'select_async';
- }
- if (['select', 'select_async'].includes(fieldToPass.type || '')) {
-
- const defaultOptions = this.getDefaultOptions(field);
- const options = this.state.optionsByField.get(field.name) || defaultOptions;
- fieldToPass = {
- ...fieldToPass,
- options,
- defaultOptions,
- defaultValue: this.getDefaultFieldValue(field),
-
- filterOption: createFilter({}),
- allowClear: !required,
- placeholder: 'Type to search',
- } as Field;
- if (field.depends_on) {
-
- const shouldDisable = field.depends_on.some(
- dependentField => !hasValue(this.model.getValue(dependentField))
- );
- if (shouldDisable) {
- fieldToPass = {...fieldToPass, disabled: true};
- }
- }
- }
- if (['text', 'textarea'].includes(fieldToPass.type || '')) {
- fieldToPass = {
- ...fieldToPass,
- defaultValue: this.getDefaultFieldValue(field),
- };
- }
-
- const extraProps = field.uri
- ? {
- loadOptions: (input: string) => this.getOptions(field, input),
- async: field?.async ?? true,
- cache: false,
- onSelectResetsInput: false,
- onCloseResetsInput: false,
- onBlurResetsInput: false,
- autoload: false,
- onChangeOption: this.createPreserveOptionFunction(field.name),
- }
- : {};
- return (
- <FieldFromConfig
- key={field.name}
- field={fieldToPass}
- data-test-id={field.name}
- {...extraProps}
- />
- );
- };
- handleAlertRuleSubmit = (formData, onSubmitSuccess) => {
- const {sentryAppInstallationUuid} = this.props;
- if (this.model.validateForm()) {
- onSubmitSuccess({
-
- settings: Object.entries(formData).map(([name, value]) => {
- const savedSetting: SentryAppSetting = {name, value};
- const stateOption = this.state.selectedOptions[name];
-
-
- if (stateOption?.value === value) {
- savedSetting.label = `${stateOption?.label}`;
- }
- return savedSetting;
- }),
- sentryAppInstallationUuid,
-
- hasSchemaFormConfig: true,
- });
- }
- };
- render() {
- const {sentryAppInstallationUuid, action, element, onSubmitSuccess} = this.props;
- const requiredFields = this.state.required_fields || [];
- const optionalFields = this.state.optional_fields || [];
- if (!sentryAppInstallationUuid) {
- return '';
- }
- return (
- <Form
- key={action}
- apiEndpoint={`/sentry-app-installations/${sentryAppInstallationUuid}/external-issue-actions/`}
- apiMethod="POST"
- // Without defining onSubmit, the Form will send an `apiMethod` request to the above `apiEndpoint`
- onSubmit={
- element === 'alert-rule-action' ? this.handleAlertRuleSubmit : undefined
- }
- onSubmitSuccess={(...params) => {
- onSubmitSuccess(...params);
- }}
- onSubmitError={this.onSubmitError}
- onFieldChange={this.handleFieldChange}
- model={this.model}
- >
- {requiredFields.map((field: FieldFromSchema) => {
- return this.renderField(field, true);
- })}
- {optionalFields.map((field: FieldFromSchema) => {
- return this.renderField(field, false);
- })}
- </Form>
- );
- }
- }
- export default withApi(SentryAppExternalForm);
|