Browse Source

ref(js): Convert SelectAsyncField to a FC (#44151)

Evan Purkhiser 2 years ago
parent
commit
27dde5825f

+ 62 - 80
static/app/components/forms/fields/selectAsyncField.tsx

@@ -1,4 +1,4 @@
-import {Component} from 'react';
+import {useState} from 'react';
 
 import SelectAsyncControl, {
   Result,
@@ -21,94 +21,76 @@ export interface SelectAsyncFieldProps
   onChangeOption?: (option: GeneralSelectValue, event: any) => void;
 }
 
-type SelectAsyncFieldState = {
-  results: Result[];
-  latestSelection?: GeneralSelectValue;
-};
-class SelectAsyncField extends Component<SelectAsyncFieldProps, SelectAsyncFieldState> {
-  state: SelectAsyncFieldState = {
-    results: [],
-    latestSelection: undefined,
-  };
+function SelectAsyncField({onChangeOption, ...props}: SelectAsyncFieldProps) {
+  const [results, setResults] = useState<Result[]>([]);
+  const [latestSelection, setLatestSelection] = useState<
+    GeneralSelectValue | undefined
+  >();
 
-  componentDidMount() {}
+  return (
+    <FormField {...props}>
+      {({
+        required: _required,
+        children: _children,
+        onBlur,
+        onChange,
+        onResults,
+        value,
+        ...fieldProps
+      }) => {
+        const {defaultOptions} = props;
+        // We don't use defaultOptions if it is undefined or a boolean
+        const options = typeof defaultOptions === 'object' ? defaultOptions : [];
+        /**
+         * The propsValue is the `id` of the object (user, team, etc), and
+         * react-select expects a full value object: {value: "id", label: "name"}
+         **/
+        const resolvedValue =
+          // When rendering the selected value, first look at the API results...
+          results.find(({value: v}) => v === value) ??
+          // Then at the defaultOptions passed in props...
+          options?.find(({value: v}) => v === value) ??
+          // Then at the latest value selected in the form
+          (latestSelection as GeneralSelectValue);
 
-  // need to map the option object to the value
-  // this is essentially the same code from ./selectField handleChange()
-  handleChange = (
-    onBlur: SelectAsyncFieldProps['onBlur'],
-    onChange: SelectAsyncFieldProps['onChange'],
-    onChangeOption: SelectAsyncFieldProps['onChangeOption'],
-    optionObj: GeneralSelectValue,
-    event: React.MouseEvent
-  ) => {
-    let {value} = optionObj;
-    if (!optionObj) {
-      value = optionObj;
-    } else if (this.props.multiple && Array.isArray(optionObj)) {
-      // List of optionObjs
-      value = optionObj.map(({value: val}) => val);
-    } else if (!Array.isArray(optionObj)) {
-      value = optionObj.value;
-    }
-    this.setState({latestSelection: optionObj});
-    onChange?.(value, event);
-    onChangeOption?.(optionObj, event);
-    onBlur?.(value, event);
-  };
-
-  findValue(propsValue: string): GeneralSelectValue {
-    const {defaultOptions} = this.props;
-    const {results, latestSelection} = this.state;
-    // We don't use defaultOptions if it is undefined or a boolean
-    const options = typeof defaultOptions === 'object' ? defaultOptions : [];
-    /**
-     * The propsValue is the `id` of the object (user, team, etc), and
-     * react-select expects a full value object: {value: "id", label: "name"}
-     **/
-    return (
-      // When rendering the selected value, first look at the API results...
-      results.find(({value}) => value === propsValue) ??
-      // Then at the defaultOptions passed in props...
-      options?.find(({value}) => value === propsValue) ??
-      // Then at the latest value selected in the form
-      (latestSelection as GeneralSelectValue)
-    );
-  }
-
-  render() {
-    const {onChangeOption, ...otherProps} = this.props;
-    return (
-      <FormField {...otherProps}>
-        {({
-          required: _required,
-          children: _children,
-          onBlur,
-          onChange,
-          onResults,
-          value,
-          ...props
-        }) => (
+        return (
           <SelectAsyncControl
-            {...props}
-            onChange={this.handleChange.bind(this, onBlur, onChange, onChangeOption)}
+            {...fieldProps}
+            onChange={(option, e) => {
+              const resultValue = !option
+                ? option
+                : props.multiple && Array.isArray(option)
+                ? // List of optionObjs
+                  option.map(({value: val}) => val)
+                : !Array.isArray(option)
+                ? option.value
+                : option;
+
+              setLatestSelection(option);
+              onChange?.(resultValue, e);
+              onChangeOption?.(option, e);
+              onBlur?.(resultValue, e);
+            }}
             onResults={data => {
-              const results = onResults(data);
-              const resultSelection = results.find(result => result.value === value);
-              this.setState(
-                resultSelection ? {results, latestSelection: resultSelection} : {results}
-              );
-              return results;
+              const newResults = onResults(data);
+              const resultSelection = newResults.find(result => result.value === value);
+
+              setResults(newResults);
+              if (resultSelection) {
+                setLatestSelection(resultSelection);
+              }
+
+              return newResults;
             }}
             onSelectResetsInput
             onCloseResetsInput={false}
             onBlurResetsInput={false}
-            value={this.findValue(value)}
+            value={resolvedValue}
           />
-        )}
-      </FormField>
-    );
-  }
+        );
+      }}
+    </FormField>
+  );
 }
 
 export default SelectAsyncField;

+ 7 - 3
static/app/components/integrationExternalMappingForm.spec.jsx

@@ -51,8 +51,9 @@ describe('IntegrationExternalMappingForm', function () {
   });
 
   // No mapping provided (e.g. Create a new mapping)
-  it('renders with no mapping provided as a form', function () {
+  it('renders with no mapping provided as a form', async function () {
     render(<IntegrationExternalMappingForm type="user" {...baseProps} />);
+    await act(tick);
     expect(screen.getByPlaceholderText('@username')).toBeInTheDocument();
     expect(screen.getByText('Select Sentry User')).toBeInTheDocument();
     expect(screen.getByTestId('form-submit')).toBeInTheDocument();
@@ -97,7 +98,7 @@ describe('IntegrationExternalMappingForm', function () {
   });
 
   // Suggested mapping provided (e.g. Create new mapping from suggested external name)
-  it('renders with a suggested mapping provided as a form', function () {
+  it('renders with a suggested mapping provided as a form', async function () {
     render(
       <IntegrationExternalMappingForm
         type="team"
@@ -105,11 +106,12 @@ describe('IntegrationExternalMappingForm', function () {
         {...baseProps}
       />
     );
+    await act(tick);
     expect(screen.getByDisplayValue(MOCK_TEAM_MAPPING.externalName)).toBeInTheDocument();
     expect(screen.getByText('Select Sentry Team')).toBeInTheDocument();
     expect(screen.getByTestId('form-submit')).toBeInTheDocument();
   });
-  it('renders with a suggested mapping provided as an inline field', function () {
+  it('renders with a suggested mapping provided as an inline field', async function () {
     render(
       <IntegrationExternalMappingForm
         isInline
@@ -118,6 +120,7 @@ describe('IntegrationExternalMappingForm', function () {
         {...baseProps}
       />
     );
+    await act(tick);
     expect(
       screen.queryByDisplayValue(MOCK_TEAM_MAPPING.externalName)
     ).not.toBeInTheDocument();
@@ -139,6 +142,7 @@ describe('IntegrationExternalMappingForm', function () {
     await act(tick);
     userEvent.click(screen.getAllByText('option2')[1]);
     userEvent.click(screen.getByTestId('form-submit'));
+    await act(tick);
     expect(baseProps.getBaseFormEndpoint).toHaveBeenCalledWith({
       externalName: MOCK_USER_MAPPING.externalName,
       integrationId: baseProps.integration.id,