Repository: laboratoriobridge/bold
Pull request #459 (2019-09-05 17:29:17)

Create SelectAsync component


Patch:
diff --git a/src/components/Select/Select.stories.tsx b/src/components/Select/Select.stories.tsx
index f3c29aa74..6bf90e3fe 100644
--- a/src/components/Select/Select.stories.tsx
+++ b/src/components/Select/Select.stories.tsx
@@ -4,6 +4,7 @@ import { storiesOf } from '@storybook/react'
 import React from 'react'
 
 import { DefaultItemType, defaultSelectFilter, Select, SelectMenu, SelectMenuItem } from './'
+import { SelectAsync } from './SelectAsync'
 
 const fruits: DefaultItemType[] = [
   { value: 1, label: 'Apple' },
@@ -21,34 +22,12 @@ const fruits: DefaultItemType[] = [
 ]
 
 const loadFruits = (query: string): Promise<DefaultItemType[]> => {
+  action('items loaded')()
   return new Promise(resolve => {
     setTimeout(() => resolve(defaultSelectFilter([...fruits], query, item => item.label)), 1000)
   })
 }
 
-interface SelectAsyncManagerProps {
-  children(renderProps: { items: any[]; loading: boolean; loadItems(query: string): void })
-}
-
-const SelectAsyncManager = (props: SelectAsyncManagerProps) => {
-  const [loading, setLoading] = React.useState(false)
-  const [items, setItems] = React.useState([])
-  const loadItems = (query: string) => {
-    setLoading(true)
-    loadFruits(query)
-      .then(data => {
-        setItems(data)
-        setLoading(false)
-      })
-      .catch(err => setLoading(false))
-  }
-  return props.children({
-    items,
-    loading,
-    loadItems,
-  })
-}
-
 // tslint:disable jsx-no-lambda
 storiesOf('Components|Select', module)
   .add('default', () => (
@@ -68,6 +47,22 @@ storiesOf('Components|Select', module)
       onBlur={action('blur')}
     />
   ))
+  .add('async', () => (
+    <SelectAsync<DefaultItemType>
+      label='Repository'
+      name='repository'
+      loadItems={loadFruits}
+      error={text('error', '')}
+      itemToString={item => item && item.label}
+      itemIsEqual={(a, b) => a.value === b.value}
+      placeholder='Select a value...'
+      multiple={boolean('multiple', false)}
+      clearable={boolean('clearable', true)}
+      disabled={boolean('disabled', false)}
+      onChange={action('changed')}
+      onBlur={action('blur')}
+    />
+  ))
   .add('custom menu item', () => (
     <Select<DefaultItemType>
       renderItem={item => (
@@ -93,28 +88,6 @@ storiesOf('Components|Select', module)
       onBlur={action('blur')}
     />
   ))
-  .add('async items', () => (
-    <SelectAsyncManager>
-      {({ items, loading, loadItems }) => (
-        <Select<DefaultItemType>
-          label='Repository'
-          name='repository'
-          items={items}
-          error={text('error', '')}
-          itemToString={item => item && item.label}
-          itemIsEqual={(a, b) => a.value === b.value}
-          placeholder='Select a value...'
-          multiple={boolean('multiple', false)}
-          clearable={boolean('clearable', true)}
-          disabled={boolean('disabled', false)}
-          loading={loading}
-          onChange={action('changed')}
-          onBlur={action('blur')}
-          onFilterChange={loadItems}
-        />
-      )}
-    </SelectAsyncManager>
-  ))
   .add('select menu', () => (
     <SelectMenu style={{ position: 'static' }}>
       <SelectMenuItem>Item 1</SelectMenuItem>
diff --git a/src/components/Select/SelectAsync.tsx b/src/components/Select/SelectAsync.tsx
new file mode 100644
index 000000000..d2ecb538f
--- /dev/null
+++ b/src/components/Select/SelectAsync.tsx
@@ -0,0 +1,27 @@
+import React from 'react'
+
+import { Select, SelectProps } from './Select'
+import { AsyncSelectLoadFn, useAsyncSelect } from './useAsyncSelect'
+
+type ProvidedProps = 'items' | 'loading' | 'onFilterChange'
+
+export interface SelectAsyncProps<T>
+  extends Omit<SelectProps<T>, ProvidedProps>,
+    Partial<Pick<SelectProps<T>, ProvidedProps>> {
+  /**
+   * Function used to populate the select.
+   */
+  loadItems: AsyncSelectLoadFn<T>
+
+  /**
+   * Delay in ms to wait before calling `loadItems`
+   */
+  delay?: number
+}
+
+export function SelectAsync<T>(props: SelectAsyncProps<T>) {
+  const { loadItems, delay, ...rest } = props
+  const { getSelectProps } = useAsyncSelect<T>(loadItems, delay)
+
+  return <Select<T> {...getSelectProps()} {...rest} />
+}
diff --git a/src/components/Select/index.ts b/src/components/Select/index.ts
index 34f68bafd..283be82cd 100644
--- a/src/components/Select/index.ts
+++ b/src/components/Select/index.ts
@@ -2,3 +2,4 @@ export * from './SelectMenu'
 export * from './SelectSingle'
 export * from './SelectMulti'
 export * from './Select'
+export * from './SelectAsync'
diff --git a/src/components/Select/useAsyncSelect.ts b/src/components/Select/useAsyncSelect.ts
new file mode 100644
index 000000000..60c18bda2
--- /dev/null
+++ b/src/components/Select/useAsyncSelect.ts
@@ -0,0 +1,41 @@
+import { useCallback, useMemo, useState } from 'react'
+
+import { debounce } from '../../util'
+
+/**
+ * Function that is used to populate the select menu.
+ *
+ * @param filter The current select input filter.
+ * @return A promise of the items to be populated.
+ */
+export type AsyncSelectLoadFn<T> = (filter: string) => Promise<T[]>
+
+export function useAsyncSelect<T>(loadItems: AsyncSelectLoadFn<T>, debounceMs = 350) {
+  const [loading, setLoading] = useState(false)
+  const [items, setItems] = useState<T[]>([])
+
+  const load = useCallback(
+    (filter: string) => {
+      setLoading(true)
+      loadItems(filter)
+        .then(loadedItems => setItems(loadedItems))
+        .finally(() => setLoading(false))
+    },
+    [loadItems]
+  )
+
+  const debouncedLoad = useMemo(() => debounce(load, debounceMs), [load])
+
+  const handleFilterChange = (filter: string) => {
+    setLoading(true)
+    return debouncedLoad(filter)
+  }
+
+  return {
+    getSelectProps: () => ({
+      items,
+      loading,
+      onFilterChange: handleFilterChange,
+    }),
+  }
+}
diff --git a/src/util/index.ts b/src/util/index.ts
index 71429e1ce..e3e219021 100644
--- a/src/util/index.ts
+++ b/src/util/index.ts
@@ -1,11 +1,13 @@
 export * from './Observable'
 export * from './types'
 
+import _debounce from 'lodash/debounce'
 import _isEmpty from 'lodash/isEmpty'
 import _isEqual from 'lodash/isEqual'
 import _merge from 'lodash/merge'
 import _some from 'lodash/some'
 
+export const debounce = _debounce
 export const isEmpty = _isEmpty
 export const isEqual = _isEqual
 export const merge = _merge


Test patch:
diff --git a/src/__snapshots__/index.test.ts.snap b/src/__snapshots__/index.test.ts.snap
index d4c55599a..7c86851d2 100644
--- a/src/__snapshots__/index.test.ts.snap
+++ b/src/__snapshots__/index.test.ts.snap
@@ -69,6 +69,7 @@ Array [
   "MultiDownshift",
   "SelectMulti",
   "Select",
+  "SelectAsync",
   "SelectInline",
   "Spinner",
   "StickyContainer",
@@ -125,6 +126,7 @@ Array [
   "colors",
   "Observable",
   "__types",
+  "debounce",
   "isEmpty",
   "isEqual",
   "merge",
diff --git a/src/components/Select/SelectAsync.test.tsx b/src/components/Select/SelectAsync.test.tsx
new file mode 100644
index 000000000..659233af7
--- /dev/null
+++ b/src/components/Select/SelectAsync.test.tsx
@@ -0,0 +1,27 @@
+import { act, render } from '@testing-library/react'
+import React from 'react'
+import waait from 'waait'
+
+import { SelectAsync } from './SelectAsync'
+
+// tslint:disable jsx-no-lambda
+
+describe('async loading', () => {
+  it('should NOT call "loadItems" when mounted', () => {
+    const loadItems = jest.fn(() => Promise.resolve(['Item 1', 'Item 2']))
+    render(<SelectAsync loadItems={loadItems} itemToString={item => item} />)
+    expect(loadItems).not.toHaveBeenCalledWith()
+  })
+
+  it('should call "loadItems" on first interaction', async () => {
+    const loadItems = jest.fn(() => Promise.resolve(['Item 1', 'Item 2']))
+    const { container } = render(<SelectAsync loadItems={loadItems} itemToString={item => item} />)
+
+    container.querySelector('input').focus()
+    await act(() => waait(300))
+    expect(loadItems).not.toHaveBeenCalledWith()
+
+    await act(() => waait(300))
+    expect(loadItems).toHaveBeenCalledWith(null)
+  })
+})