Browse Source

chore(ts): Convert AsyncComponentSearchInput (#15618)

Cleans up some more things with asyncComponent as well
Evan Purkhiser 5 years ago
parent
commit
3fd6386943

+ 48 - 35
src/sentry/static/sentry/app/components/asyncComponent.tsx

@@ -34,8 +34,25 @@ type AsyncComponentState = {
   [key: string]: any;
 };
 
-function wrapErrorHandling(component, fn) {
-  return (...args) => {
+type SearchInputProps = React.ComponentProps<typeof AsyncComponentSearchInput>;
+
+type RenderSearchInputArgs = Omit<
+  SearchInputProps,
+  'api' | 'onSuccess' | 'onError' | 'url'
+> & {
+  stateKey?: string;
+  url?: SearchInputProps['url'];
+};
+
+/**
+ * Wraps methods on the AsyncComponent to catch errors and set the `error`
+ * state on error.
+ */
+function wrapErrorHandling<T extends any[], U>(
+  component: AsyncComponent,
+  fn: (...args: T) => U
+) {
+  return (...args: T): U | null => {
     try {
       return fn(...args);
     } catch (error) {
@@ -86,7 +103,7 @@ export default class AsyncComponent<
   // should `renderError` render the `detail` attribute of a 400 error
   shouldRenderBadRequests = false;
 
-  constructor(props, context) {
+  constructor(props: P, context: any) {
     super(props, context);
 
     this.fetchData = wrapErrorHandling(this, this.fetchData.bind(this));
@@ -112,9 +129,9 @@ export default class AsyncComponent<
   }
 
   // Compatiblity shim for child classes that call super on this hook.
-  componentWillReceiveProps(_newProps, _newContext) {}
+  componentWillReceiveProps(_newProps: P, _newContext: any) {}
 
-  componentDidUpdate(prevProps, prevContext) {
+  componentDidUpdate(prevProps: P, prevContext: any) {
     const isRouterInContext = !!prevContext.router;
     const isLocationInProps = prevProps.location !== undefined;
 
@@ -207,12 +224,7 @@ export default class AsyncComponent<
 
   remountComponent = () => {
     if (this.shouldReload) {
-      this.setState(
-        {
-          reloading: true,
-        },
-        this.fetchData
-      );
+      this.setState({reloading: true}, this.fetchData);
     } else {
       this.setState(this.getDefaultState(), this.fetchData);
     }
@@ -224,7 +236,9 @@ export default class AsyncComponent<
     !document.hidden &&
     this.reloadData();
 
-  reloadData = () => this.fetchData({reloading: true});
+  reloadData() {
+    this.fetchData({reloading: true});
+  }
 
   fetchData = (extraState?: object) => {
     const endpoints = this.getEndpoints();
@@ -244,7 +258,7 @@ export default class AsyncComponent<
       ...extraState,
     });
 
-    endpoints.forEach(([stateKey, endpoint, params, options]: any) => {
+    endpoints.forEach(([stateKey, endpoint, params, options]) => {
       options = options || {};
       // If you're using nested async components/views make sure to pass the
       // props through so that the child component has access to props.location
@@ -283,7 +297,7 @@ export default class AsyncComponent<
     // Allow children to implement this
   }
 
-  handleRequestSuccess = ({stateKey, data, jqXHR}, initialRequest?: boolean) => {
+  handleRequestSuccess({stateKey, data, jqXHR}, initialRequest?: boolean) {
     this.setState(prevState => {
       const state = {
         [stateKey]: data,
@@ -301,7 +315,7 @@ export default class AsyncComponent<
       return state;
     });
     this.onRequestSuccess({stateKey, data, jqXHR});
-  };
+  }
 
   handleError(error, args) {
     const [stateKey] = args;
@@ -332,14 +346,18 @@ export default class AsyncComponent<
     this.onRequestError(error, args);
   }
 
-  // DEPRECATED: use getEndpoints()
+  /**
+   * @deprecated use getEndpoints
+   */
   getEndpointParams() {
     // eslint-disable-next-line no-console
     console.warn('getEndpointParams is deprecated');
     return {};
   }
 
-  // DEPRECATED: use getEndpoints()
+  /**
+   * @deprecated use getEndpoints
+   */
   getEndpoint() {
     // eslint-disable-next-line no-console
     console.warn('getEndpoint is deprecated');
@@ -361,22 +379,14 @@ export default class AsyncComponent<
     return [['data', endpoint, this.getEndpointParams()]];
   }
 
-  renderSearchInput({
-    onSearchSubmit,
-    stateKey,
-    url,
-    updateRoute,
-    ...other
-  }: React.ComponentProps<typeof AsyncComponentSearchInput>) {
-    const [firstEndpoint]: any = this.getEndpoints() || [];
+  renderSearchInput({stateKey, url, ...props}: RenderSearchInputArgs) {
+    const [firstEndpoint] = this.getEndpoints() || [null];
     const stateKeyOrDefault = stateKey || (firstEndpoint && firstEndpoint[0]);
     const urlOrDefault = url || (firstEndpoint && firstEndpoint[1]);
     return (
       <AsyncComponentSearchInput
-        updateRoute={updateRoute}
-        onSearchSubmit={onSearchSubmit}
-        stateKey={stateKeyOrDefault}
         url={urlOrDefault}
+        {...props}
         api={this.api}
         onSuccess={(data, jqXHR) => {
           this.handleRequestSuccess({stateKey: stateKeyOrDefault, data, jqXHR});
@@ -384,7 +394,6 @@ export default class AsyncComponent<
         onError={() => {
           this.renderError(new Error('Error with AsyncComponentSearchInput'));
         }}
-        {...other}
       />
     );
   }
@@ -393,22 +402,23 @@ export default class AsyncComponent<
     return <LoadingIndicator />;
   }
 
-  renderError(error?, disableLog = false, disableReport = false): React.ReactNode {
+  renderError(error?: Error, disableLog = false, disableReport = false): React.ReactNode {
+    const {errors} = this.state;
+
     // 401s are captured by SudoModal, but may be passed back to AsyncComponent if they close the modal without identifying
-    const unauthorizedErrors = Object.values(this.state.errors).find(
+    const unauthorizedErrors = Object.values(errors).find(
       resp => resp && resp.status === 401
     );
 
     // Look through endpoint results to see if we had any 403s, means their role can not access resource
-    const permissionErrors = Object.values(this.state.errors).find(
+    const permissionErrors = Object.values(errors).find(
       resp => resp && resp.status === 403
     );
 
     // If all error responses have status code === 0, then show error message but don't
     // log it to sentry
     const shouldLogSentry =
-      !!Object.values(this.state.errors).find(resp => resp && resp.status !== 0) ||
-      disableLog;
+      !!Object.values(errors).find(resp => resp && resp.status !== 0) || disableLog;
 
     if (unauthorizedErrors) {
       return (
@@ -421,7 +431,7 @@ export default class AsyncComponent<
     }
 
     if (this.shouldRenderBadRequests) {
-      const badRequests = Object.values(this.state.errors)
+      const badRequests = Object.values(errors)
         .filter(
           resp =>
             resp && resp.status === 400 && resp.responseJSON && resp.responseJSON.detail
@@ -452,6 +462,9 @@ export default class AsyncComponent<
       : this.renderBody();
   }
 
+  /**
+   * Renders once all endpoints have been loaded
+   */
   renderBody(): React.ReactNode {
     // Allow children to implement this
     throw new Error('Not implemented');

+ 0 - 121
src/sentry/static/sentry/app/components/asyncComponentSearchInput.jsx

@@ -1,121 +0,0 @@
-import debounce from 'lodash/debounce';
-import {withRouter} from 'react-router';
-import PropTypes from 'prop-types';
-import React from 'react';
-import styled from 'react-emotion';
-
-import Input from 'app/views/settings/components/forms/controls/input';
-import LoadingIndicator from 'app/components/loadingIndicator';
-
-/**
- * This is a search input that can be easily used in AsyncComponent/Views.
- *
- * It probably doesn't make too much sense outside of an AsyncComponent atm.
- */
-class AsyncComponentSearchInput extends React.Component {
-  static propTypes = {
-    api: PropTypes.any.isRequired,
-    url: PropTypes.string.isRequired,
-    onSuccess: PropTypes.func.isRequired,
-    onError: PropTypes.func.isRequired,
-    // Updates URL with search query in the URL param: `query`
-    updateRoute: PropTypes.bool,
-    router: PropTypes.object.isRequired,
-    placeholder: PropTypes.string,
-    onSearchSubmit: PropTypes.func,
-    debounceWait: PropTypes.number,
-  };
-
-  static defaultProps = {
-    placeholder: 'Search...',
-    debounceWait: 200,
-  };
-
-  constructor(props) {
-    super(props);
-    this.state = {
-      query: '',
-      busy: false,
-    };
-  }
-
-  query = debounce(searchQuery => {
-    const {router} = this.props;
-    this.setState({busy: true});
-    return this.props.api.request(`${this.props.url}`, {
-      method: 'GET',
-      query: {
-        ...router.location.query,
-        query: searchQuery,
-      },
-      success: (data, _, jqXHR) => {
-        this.setState({busy: false});
-        // only update data if the request's query matches the current query
-        if (this.state.query === searchQuery) {
-          this.props.onSuccess(data, jqXHR);
-        }
-      },
-      error: () => {
-        this.setState({busy: false});
-        this.props.onError();
-      },
-    });
-  }, this.props.debounceWait);
-
-  handleChange = evt => {
-    const searchQuery = evt.target.value;
-    this.query(searchQuery);
-    this.setState({query: searchQuery});
-  };
-
-  /**
-   * This is called when "Enter" (more specifically a form "submit" event) is pressed.
-   */
-  handleSearch = evt => {
-    const {updateRoute, onSearchSubmit} = this.props;
-    evt.preventDefault();
-
-    // Update the URL to reflect search term.
-    if (updateRoute) {
-      const {router, location} = this.props;
-      router.push({
-        pathname: location.pathname,
-        query: {
-          query: this.state.query,
-        },
-      });
-    }
-
-    if (typeof onSearchSubmit !== 'function') {
-      return;
-    }
-    onSearchSubmit(this.state.query, evt);
-  };
-
-  render() {
-    const {placeholder, className} = this.props;
-    return (
-      <Form onSubmit={this.handleSearch}>
-        <Input
-          value={this.state.query}
-          onChange={this.handleChange}
-          className={className}
-          placeholder={placeholder}
-        />
-        {this.state.busy && <StyledLoadingIndicator size="18px" mini />}
-      </Form>
-    );
-  }
-}
-
-const StyledLoadingIndicator = styled(LoadingIndicator)`
-  position: absolute;
-  right: -6px;
-  top: 3px;
-`;
-
-const Form = styled('form')`
-  position: relative;
-`;
-
-export default withRouter(AsyncComponentSearchInput);

+ 143 - 0
src/sentry/static/sentry/app/components/asyncComponentSearchInput.tsx

@@ -0,0 +1,143 @@
+import * as ReactRouter from 'react-router';
+import debounce from 'lodash/debounce';
+import React from 'react';
+import styled from 'react-emotion';
+
+import Input from 'app/views/settings/components/forms/controls/input';
+import LoadingIndicator from 'app/components/loadingIndicator';
+import {Client} from 'app/api';
+
+type Props = ReactRouter.WithRouterProps & {
+  api: Client;
+  className?: string;
+  /**
+   * URL to make the search request to
+   */
+  url: string;
+  /**
+   * Placeholder text in the search input
+   */
+  placeholder?: string;
+  /**
+   * Time in milliseconds to wait before firing off the request
+   */
+  debounceWait?: number;
+  /**
+   * Updates URL with search query in the URL param: `query`
+   */
+  updateRoute?: boolean;
+
+  onSearchSubmit?: (query: string, event: React.FormEvent) => void;
+  onSuccess: (data: object, jqXHR: JQueryXHR | undefined) => void;
+  onError: () => void;
+};
+
+type State = {
+  query: string;
+  busy: boolean;
+};
+
+/**
+ * This is a search input that can be easily used in AsyncComponent/Views.
+ *
+ * It probably doesn't make too much sense outside of an AsyncComponent atm.
+ */
+class AsyncComponentSearchInput extends React.Component<Props, State> {
+  static defaultProps = {
+    placeholder: 'Search...',
+    debounceWait: 200,
+  };
+
+  state: State = {
+    query: '',
+    busy: false,
+  };
+
+  immediateQuery = async (searchQuery: string) => {
+    const {location, api} = this.props;
+    this.setState({busy: true});
+
+    try {
+      const [data, , jqXHR] = await api.requestPromise(`${this.props.url}`, {
+        includeAllArgs: true,
+        method: 'GET',
+        query: {...location.query, query: searchQuery},
+      });
+      // only update data if the request's query matches the current query
+      if (this.state.query === searchQuery) {
+        this.props.onSuccess(data, jqXHR);
+      }
+    } catch {
+      this.props.onError();
+    }
+
+    this.setState({busy: false});
+  };
+
+  query = debounce(this.immediateQuery, this.props.debounceWait);
+
+  handleChange = (evt: React.ChangeEvent<HTMLInputElement>) => {
+    const searchQuery = evt.target.value;
+    this.query(searchQuery);
+    this.setState({query: searchQuery});
+  };
+
+  /**
+   * This is called when "Enter" (more specifically a form "submit" event) is pressed.
+   */
+  handleSearch = (evt: React.FormEvent<HTMLFormElement>) => {
+    const {updateRoute, onSearchSubmit} = this.props;
+    evt.preventDefault();
+
+    // Update the URL to reflect search term.
+    if (updateRoute) {
+      const {router, location} = this.props;
+      router.push({
+        pathname: location.pathname,
+        query: {
+          query: this.state.query,
+        },
+      });
+    }
+
+    if (typeof onSearchSubmit !== 'function') {
+      return;
+    }
+    onSearchSubmit(this.state.query, evt);
+  };
+
+  render() {
+    const {placeholder, className} = this.props;
+    return (
+      <Form onSubmit={this.handleSearch}>
+        <Input
+          value={this.state.query}
+          onChange={this.handleChange}
+          className={className}
+          placeholder={placeholder}
+        />
+        {this.state.busy && <StyledLoadingIndicator size={18} hideMessage mini />}
+      </Form>
+    );
+  }
+}
+
+const StyledLoadingIndicator = styled(LoadingIndicator)`
+  position: absolute;
+  right: 25px;
+  top: 50%;
+  transform: translateY(-13px);
+`;
+
+const Form = styled('form')`
+  position: relative;
+`;
+
+// XXX(epurkhiser): The withRouter HoC has incorrect typings. It does not
+// correctly remove the WithRouterProps from the return type of the HoC, thus
+// we manually have to do this.
+type PropsWithoutRouter = Omit<Props, keyof ReactRouter.WithRouterProps>;
+
+export default ReactRouter.withRouter(AsyncComponentSearchInput) as React.ComponentClass<
+  PropsWithoutRouter
+>;

+ 1 - 4
tests/js/spec/views/__snapshots__/projectDebugFiles.spec.jsx.snap

@@ -145,7 +145,6 @@ exports[`ProjectDebugFiles renders 1`] = `
                     onError={[Function]}
                     onSuccess={[Function]}
                     placeholder="Search DIFs"
-                    stateKey="project"
                     updateRoute={true}
                     url="/projects/org/project/"
                   >
@@ -185,7 +184,6 @@ exports[`ProjectDebugFiles renders 1`] = `
                           "setRouteLeaveHook": [MockFunction],
                         }
                       }
-                      stateKey="project"
                       updateRoute={true}
                       url="/projects/org/project/"
                     >
@@ -193,7 +191,7 @@ exports[`ProjectDebugFiles renders 1`] = `
                         onSubmit={[Function]}
                       >
                         <form
-                          className="css-vfrmj7-Form et3a3ng1"
+                          className="css-vfrmj7-Form e6xwo1"
                           onSubmit={[Function]}
                         >
                           <Input
@@ -325,7 +323,6 @@ exports[`ProjectDebugFiles renders empty 1`] = `
           onError={[Function]}
           onSuccess={[Function]}
           placeholder="Search DIFs"
-          stateKey="project"
           updateRoute={true}
           url="/projects/org/project/"
         />

+ 1 - 3
tests/js/spec/views/settings/__snapshots__/organizationProjects.spec.jsx.snap

@@ -264,7 +264,6 @@ exports[`OrganizationProjects should render the projects in the store 1`] = `
                       onError={[Function]}
                       onSuccess={[Function]}
                       placeholder="Search Projects"
-                      stateKey="projectList"
                       updateRoute={true}
                       url="/organizations/org-slug/projects/"
                     >
@@ -323,7 +322,6 @@ exports[`OrganizationProjects should render the projects in the store 1`] = `
                             "setRouteLeaveHook": [MockFunction],
                           }
                         }
-                        stateKey="projectList"
                         updateRoute={true}
                         url="/organizations/org-slug/projects/"
                       >
@@ -331,7 +329,7 @@ exports[`OrganizationProjects should render the projects in the store 1`] = `
                           onSubmit={[Function]}
                         >
                           <form
-                            className="css-vfrmj7-Form et3a3ng1"
+                            className="css-vfrmj7-Form e6xwo1"
                             onSubmit={[Function]}
                           >
                             <Input