You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by mi...@apache.org on 2021/06/22 19:04:51 UTC

[superset] branch master updated: chore: Improves the Select component UI/UX - iteration 2 (#15235)

This is an automated email from the ASF dual-hosted git repository.

michaelsmolina pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new fc1a62b  chore: Improves the Select component UI/UX - iteration 2 (#15235)
fc1a62b is described below

commit fc1a62b78c646ba3216e749447efbc1ee6478c0f
Author: Michael S. Molina <70...@users.noreply.github.com>
AuthorDate: Tue Jun 22 16:04:01 2021 -0300

    chore: Improves the Select component UI/UX - iteration 2 (#15235)
---
 .../src/components/Select/AntdSelect.stories.tsx   | 244 ----------------
 .../src/components/Select/Select.stories.tsx       | 313 +++++++++++++++++++++
 .../Select/{AntdSelect.tsx => Select.tsx}          |  78 ++---
 superset-frontend/src/components/Select/utils.ts   |   2 +-
 superset-frontend/src/components/index.ts          |   2 +-
 5 files changed, 361 insertions(+), 278 deletions(-)

diff --git a/superset-frontend/src/components/Select/AntdSelect.stories.tsx b/superset-frontend/src/components/Select/AntdSelect.stories.tsx
deleted file mode 100644
index e26588a..0000000
--- a/superset-frontend/src/components/Select/AntdSelect.stories.tsx
+++ /dev/null
@@ -1,244 +0,0 @@
-/**
- * Licensed to the Apache Software Foundation (ASF) under one
- * or more contributor license agreements.  See the NOTICE file
- * distributed with this work for additional information
- * regarding copyright ownership.  The ASF licenses this file
- * to you under the Apache License, Version 2.0 (the
- * "License"); you may not use this file except in compliance
- * with the License.  You may obtain a copy of the License at
- *
- *   http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing,
- * software distributed under the License is distributed on an
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
- * KIND, either express or implied.  See the License for the
- * specific language governing permissions and limitations
- * under the License.
- */
-import React from 'react';
-import Select, { OptionsType, SelectProps } from './AntdSelect';
-
-export default {
-  title: 'Select',
-  component: Select,
-};
-
-const options = [
-  {
-    label: 'Such an incredibly awesome long long label',
-    value: 'Such an incredibly awesome long long label',
-  },
-  {
-    label: 'Another incredibly awesome long long label',
-    value: 'Another incredibly awesome long long label',
-  },
-  { label: 'Just a label', value: 'Just a label' },
-  { label: 'A', value: 'A' },
-  { label: 'B', value: 'B' },
-  { label: 'C', value: 'C' },
-  { label: 'D', value: 'D' },
-  { label: 'E', value: 'E' },
-  { label: 'F', value: 'F' },
-  { label: 'G', value: 'G' },
-  { label: 'H', value: 'H' },
-  { label: 'I', value: 'I' },
-];
-
-const selectPositions = [
-  {
-    id: 'topLeft',
-    style: { top: '0', left: '0' },
-  },
-  {
-    id: 'topRight',
-    style: { top: '0', right: '0' },
-  },
-  {
-    id: 'bottomLeft',
-    style: { bottom: '0', left: '0' },
-  },
-  {
-    id: 'bottomRight',
-    style: { bottom: '0', right: '0' },
-  },
-];
-
-export const AtEveryCorner = () => (
-  <>
-    {selectPositions.map(position => (
-      <div
-        key={position.id}
-        style={{
-          ...position.style,
-          width: '120px',
-          position: 'absolute',
-        }}
-      >
-        <Select ariaLabel={`gallery-${position.id}`} options={options} />
-      </div>
-    ))}
-  </>
-);
-
-AtEveryCorner.story = {
-  parameters: {
-    actions: {
-      disable: true,
-    },
-    controls: {
-      disable: true,
-    },
-    knobs: {
-      disable: true,
-    },
-  },
-};
-
-async function fetchUserList(search: string, page = 0): Promise<OptionsType> {
-  const username = search.trim().toLowerCase();
-  return new Promise(resolve => {
-    const users = [
-      'John',
-      'Liam',
-      'Olivia',
-      'Emma',
-      'Noah',
-      'Ava',
-      'Oliver',
-      'Elijah',
-      'Charlotte',
-      'Diego',
-      'Evan',
-      'Michael',
-      'Giovanni',
-      'Luca',
-      'Paolo',
-      'Francesca',
-      'Chiara',
-      'Sara',
-      'Valentina',
-      'Jessica',
-      'Angelica',
-      'Mario',
-      'Marco',
-      'Andrea',
-      'Luigi',
-      'Quarto',
-      'Quinto',
-      'Sesto',
-      'Franco',
-      'Sandro',
-      'Alehandro',
-      'Johnny',
-      'Nikole',
-      'Igor',
-      'Sipatha',
-      'Thami',
-      'Munei',
-      'Guilherme',
-      'Umair',
-      'Ashfaq',
-      'Amna',
-      'Irfan',
-      'George',
-      'Naseer',
-      'Mohammad',
-      'Rick',
-      'Saliya',
-      'Claire',
-      'Benedetta',
-      'Ilenia',
-    ];
-
-    let results: { label: string; value: string }[] = [];
-
-    if (!username) {
-      results = users.map(u => ({
-        label: u,
-        value: u,
-      }));
-    } else {
-      const foundUsers = users.find(u => u.toLowerCase().includes(username));
-      if (foundUsers && Array.isArray(foundUsers)) {
-        results = foundUsers.map(u => ({ label: u, value: u }));
-      }
-      if (foundUsers && typeof foundUsers === 'string') {
-        const u = foundUsers;
-        results = [{ label: u, value: u }];
-      }
-    }
-    const offset = !page ? 0 : page * 10;
-    const resultsNum = !page ? 10 : (page + 1) * 10;
-    results = results.length ? results.splice(offset, resultsNum) : [];
-
-    // eslint-disable-next-line no-console
-    console.warn(
-      `Emulating network request for search string: ${
-        username || '"empty"'
-      } and page: ${page} with results: [${results
-        .map(u => u.value)
-        .join(', ')}]`,
-    );
-
-    setTimeout(() => {
-      resolve(results);
-    }, 500);
-  });
-}
-
-async function fetchUserListError(): Promise<OptionsType> {
-  return new Promise((_, reject) => {
-    // eslint-disable-next-line prefer-promise-reject-errors
-    reject('This is an error');
-  });
-}
-
-export const AsyncSelect = (args: SelectProps & { withError: boolean }) => (
-  <Select
-    {...args}
-    options={args.withError ? fetchUserListError : fetchUserList}
-  />
-);
-
-AsyncSelect.args = {
-  withError: false,
-  allowNewOptions: false,
-  paginatedFetch: false,
-};
-
-AsyncSelect.argTypes = {
-  mode: {
-    control: { type: 'select', options: ['single', 'multiple', 'tags'] },
-  },
-};
-
-AsyncSelect.story = {
-  parameters: {
-    knobs: {
-      disable: true,
-    },
-  },
-};
-
-export const InteractiveSelect = (args: SelectProps) => <Select {...args} />;
-
-InteractiveSelect.args = {
-  allowNewOptions: false,
-  options,
-  showSearch: false,
-};
-
-InteractiveSelect.argTypes = {
-  mode: {
-    control: { type: 'select', options: ['single', 'multiple', 'tags'] },
-  },
-};
-
-InteractiveSelect.story = {
-  parameters: {
-    knobs: {
-      disable: true,
-    },
-  },
-};
diff --git a/superset-frontend/src/components/Select/Select.stories.tsx b/superset-frontend/src/components/Select/Select.stories.tsx
new file mode 100644
index 0000000..8454712
--- /dev/null
+++ b/superset-frontend/src/components/Select/Select.stories.tsx
@@ -0,0 +1,313 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import React, { ReactNode, useState, useCallback } from 'react';
+import Select, { SelectProps, OptionsPromiseResult } from './Select';
+
+export default {
+  title: 'Select',
+  component: Select,
+};
+
+const DEFAULT_WIDTH = 200;
+
+const options = [
+  {
+    label: 'Such an incredibly awesome long long label',
+    value: 'Such an incredibly awesome long long label',
+  },
+  {
+    label: 'Another incredibly awesome long long label',
+    value: 'Another incredibly awesome long long label',
+  },
+  { label: 'Just a label', value: 'Just a label' },
+  { label: 'A', value: 'A' },
+  { label: 'B', value: 'B' },
+  { label: 'C', value: 'C' },
+  { label: 'D', value: 'D' },
+  { label: 'E', value: 'E' },
+  { label: 'F', value: 'F' },
+  { label: 'G', value: 'G' },
+  { label: 'H', value: 'H' },
+  { label: 'I', value: 'I' },
+];
+
+const selectPositions = [
+  {
+    id: 'topLeft',
+    style: { top: '0', left: '0' },
+  },
+  {
+    id: 'topRight',
+    style: { top: '0', right: '0' },
+  },
+  {
+    id: 'bottomLeft',
+    style: { bottom: '0', left: '0' },
+  },
+  {
+    id: 'bottomRight',
+    style: { bottom: '0', right: '0' },
+  },
+];
+
+export const AtEveryCorner = () => (
+  <>
+    {selectPositions.map(position => (
+      <div
+        key={position.id}
+        style={{
+          ...position.style,
+          width: DEFAULT_WIDTH,
+          position: 'absolute',
+        }}
+      >
+        <Select ariaLabel={`gallery-${position.id}`} options={options} />
+      </div>
+    ))}
+    <p style={{ position: 'absolute', top: '40%', left: '33%', width: 500 }}>
+      The objective of this panel is to show how the Select behaves when in
+      touch with the viewport extremities. In particular, how the drop-down is
+      displayed and if the tooltips of truncated items are correctly positioned.
+    </p>
+  </>
+);
+
+AtEveryCorner.story = {
+  parameters: {
+    actions: {
+      disable: true,
+    },
+    controls: {
+      disable: true,
+    },
+    knobs: {
+      disable: true,
+    },
+  },
+};
+
+const USERS = [
+  'John',
+  'Liam',
+  'Olivia',
+  'Emma',
+  'Noah',
+  'Ava',
+  'Oliver',
+  'Elijah',
+  'Charlotte',
+  'Diego',
+  'Evan',
+  'Michael',
+  'Giovanni',
+  'Luca',
+  'Paolo',
+  'Francesca',
+  'Chiara',
+  'Sara',
+  'Valentina',
+  'Jessica',
+  'Angelica',
+  'Mario',
+  'Marco',
+  'Andrea',
+  'Luigi',
+  'Quarto',
+  'Quinto',
+  'Sesto',
+  'Franco',
+  'Sandro',
+  'Alehandro',
+  'Johnny',
+  'Nikole',
+  'Igor',
+  'Sipatha',
+  'Thami',
+  'Munei',
+  'Guilherme',
+  'Umair',
+  'Ashfaq',
+  'Amna',
+  'Irfan',
+  'George',
+  'Naseer',
+  'Mohammad',
+  'Rick',
+  'Saliya',
+  'Claire',
+  'Benedetta',
+  'Ilenia',
+];
+
+export const AsyncSelect = (
+  args: SelectProps & { withError: boolean; responseTime: number },
+) => {
+  const [requests, setRequests] = useState<ReactNode[]>([]);
+
+  const fetchUserList = useCallback(
+    (search: string, page = 0): Promise<OptionsPromiseResult> => {
+      const username = search.trim().toLowerCase();
+      return new Promise(resolve => {
+        let results: { label: string; value: string }[] = [];
+
+        if (!username) {
+          results = USERS.map(u => ({
+            label: u,
+            value: u,
+          }));
+        } else {
+          const foundUsers = USERS.find(u =>
+            u.toLowerCase().includes(username),
+          );
+          if (foundUsers && Array.isArray(foundUsers)) {
+            results = foundUsers.map(u => ({ label: u, value: u }));
+          }
+          if (foundUsers && typeof foundUsers === 'string') {
+            const u = foundUsers;
+            results = [{ label: u, value: u }];
+          }
+        }
+
+        const pageSize = 10;
+        const offset = !page ? 0 : page * pageSize;
+        const resultsNum = !page ? pageSize : (page + 1) * pageSize;
+        results = results.length ? results.splice(offset, resultsNum) : [];
+
+        const request = (
+          <>
+            Emulating network request for page <b>{page}</b> and search{' '}
+            <b>{username || 'empty'}</b> ... <b>{resultsNum}</b> results
+          </>
+        );
+
+        setRequests(requests => [request, ...requests]);
+
+        const totalPages =
+          USERS.length / pageSize + (USERS.length % pageSize > 0 ? 1 : 0);
+
+        const result: OptionsPromiseResult = {
+          data: results,
+          hasMoreData: page + 1 < totalPages,
+        };
+
+        setTimeout(() => {
+          resolve(result);
+        }, args.responseTime * 1000);
+      });
+    },
+    [args.responseTime],
+  );
+
+  async function fetchUserListError(): Promise<OptionsPromiseResult> {
+    return new Promise((_, reject) => {
+      // eslint-disable-next-line prefer-promise-reject-errors
+      reject('This is an error');
+    });
+  }
+
+  return (
+    <>
+      <div
+        style={{
+          width: DEFAULT_WIDTH,
+        }}
+      >
+        <Select
+          {...args}
+          options={args.withError ? fetchUserListError : fetchUserList}
+        />
+      </div>
+      <div
+        style={{
+          position: 'absolute',
+          top: 32,
+          left: DEFAULT_WIDTH + 100,
+          height: 400,
+          width: 600,
+          overflowY: 'auto',
+          border: '1px solid #d9d9d9',
+          padding: 20,
+        }}
+      >
+        {requests.map(request => (
+          <p>{request}</p>
+        ))}
+      </div>
+    </>
+  );
+};
+
+AsyncSelect.args = {
+  withError: false,
+  allowNewOptions: false,
+  paginatedFetch: false,
+};
+
+AsyncSelect.argTypes = {
+  mode: {
+    control: { type: 'select', options: ['single', 'multiple', 'tags'] },
+  },
+  responseTime: {
+    defaultValue: 1,
+    name: 'responseTime (seconds)',
+    control: {
+      type: 'range',
+      min: 1,
+      max: 5,
+    },
+  },
+};
+
+AsyncSelect.story = {
+  parameters: {
+    knobs: {
+      disable: true,
+    },
+  },
+};
+
+export const InteractiveSelect = (args: SelectProps) => (
+  <div
+    style={{
+      width: DEFAULT_WIDTH,
+    }}
+  >
+    <Select {...args} />
+  </div>
+);
+
+InteractiveSelect.args = {
+  allowNewOptions: false,
+  options,
+  showSearch: false,
+};
+
+InteractiveSelect.argTypes = {
+  mode: {
+    control: { type: 'select', options: ['single', 'multiple', 'tags'] },
+  },
+};
+
+InteractiveSelect.story = {
+  parameters: {
+    knobs: {
+      disable: true,
+    },
+  },
+};
diff --git a/superset-frontend/src/components/Select/AntdSelect.tsx b/superset-frontend/src/components/Select/Select.tsx
similarity index 89%
rename from superset-frontend/src/components/Select/AntdSelect.tsx
rename to superset-frontend/src/components/Select/Select.tsx
index da8fe1a..f7df21e 100644
--- a/superset-frontend/src/components/Select/AntdSelect.tsx
+++ b/superset-frontend/src/components/Select/Select.tsx
@@ -39,6 +39,7 @@ import { getClientErrorObject } from 'src/utils/getClientErrorObject';
 import { hasOption } from './utils';
 
 type AntdSelectAllProps = AntdSelectProps<AntdSelectValue>;
+
 type PickedSelectProps = Pick<
   AntdSelectAllProps,
   | 'allowClear'
@@ -55,16 +56,25 @@ type PickedSelectProps = Pick<
   | 'showSearch'
   | 'value'
 >;
+
 export type OptionsType = Exclude<AntdSelectAllProps['options'], undefined>;
+
+export type OptionsPromiseResult = {
+  data: OptionsType;
+  hasMoreData: boolean;
+};
+
 export type OptionsPromise = (
   search: string,
   page?: number,
-) => Promise<OptionsType>;
+) => Promise<OptionsPromiseResult>;
+
 export enum ESelectTypes {
   MULTIPLE = 'multiple',
   TAGS = 'tags',
   SINGLE = '',
 }
+
 export interface SelectProps extends PickedSelectProps {
   allowNewOptions?: boolean;
   ariaLabel: string;
@@ -75,10 +85,15 @@ export interface SelectProps extends PickedSelectProps {
   paginatedFetch?: boolean;
 }
 
+const StyledContainer = styled.div`
+  display: flex;
+  flex-direction: column;
+`;
+
 // unexposed default behaviors
 const MAX_TAG_COUNT = 4;
 const TOKEN_SEPARATORS = [',', '\n', '\t', ';'];
-const DEBOUNCE_TIMEOUT = 800;
+const DEBOUNCE_TIMEOUT = 500;
 
 const Error = ({ error }: { error: string }) => {
   const StyledError = styled.div`
@@ -108,7 +123,7 @@ const DropdownContent = ({
   return content;
 };
 
-const SelectComponent = ({
+const Select = ({
   allowNewOptions = false,
   ariaLabel,
   filterOption,
@@ -135,6 +150,7 @@ const SelectComponent = ({
   const [isLoading, setLoading] = useState(loading);
   const [error, setError] = useState('');
   const [isDropdownVisible, setIsDropdownVisible] = useState(false);
+  const [hasMoreData, setHasMoreData] = useState(false);
   const fetchRef = useRef(0);
 
   const handleSelectMode = () => {
@@ -191,9 +207,8 @@ const SelectComponent = ({
     }
   };
 
-  const handleFetch = useMemo(() => {
-    const fetchOptions = options as OptionsPromise;
-    const loadOptions = (value: string, paginate?: 'paginate') => {
+  const handleFetch = useMemo(
+    () => (value: string, paginate?: 'paginate') => {
       if (paginate) {
         fetchRef.current += 1;
       } else {
@@ -201,15 +216,17 @@ const SelectComponent = ({
       }
       const fetchId = fetchRef.current;
       const page = paginatedFetch ? fetchId : undefined;
-
+      const fetchOptions = options as OptionsPromise;
       fetchOptions(value, page)
-        .then((newOptions: OptionsType) => {
+        .then((result: OptionsPromiseResult) => {
+          const { data, hasMoreData } = result;
+          setHasMoreData(hasMoreData);
           if (fetchId !== fetchRef.current) return;
-          if (newOptions && Array.isArray(newOptions) && newOptions.length) {
+          if (data && Array.isArray(data) && data.length) {
             // merges with existing and creates unique options
             setOptions(prevOptions => [
               ...prevOptions,
-              ...newOptions.filter(
+              ...data.filter(
                 newOpt =>
                   !prevOptions.find(prevOpt => prevOpt.value === newOpt.value),
               ),
@@ -223,11 +240,11 @@ const SelectComponent = ({
           }),
         )
         .finally(() => setLoading(false));
-    };
-    return debounce(loadOptions, DEBOUNCE_TIMEOUT);
-  }, [options, paginatedFetch]);
+    },
+    [options, paginatedFetch],
+  );
 
-  const handleOnSearch = (search: string) => {
+  const handleOnSearch = debounce((search: string) => {
     const searchValue = search.trim();
     // enables option creation
     if (allowNewOptions && isSingleMode) {
@@ -252,11 +269,12 @@ const SelectComponent = ({
       }
     }
     setSearchedValue(searchValue);
-  };
+  }, DEBOUNCE_TIMEOUT);
 
   const handlePagination = (e: UIEvent<HTMLElement>) => {
     const vScroll = e.currentTarget;
     if (
+      hasMoreData &&
       isAsync &&
       paginatedFetch &&
       vScroll.scrollTop === vScroll.scrollHeight - vScroll.offsetHeight
@@ -310,19 +328,21 @@ const SelectComponent = ({
     }
   }, [allowNewOptions, isAsync, handleFetch, searchedValue]);
 
+  const dropdownRender = (
+    originNode: ReactElement & { ref?: RefObject<HTMLElement> },
+  ) => {
+    if (!isDropdownVisible) {
+      originNode.ref?.current?.scrollTo({ top: 0 });
+    }
+    return <DropdownContent content={originNode} error={error} />;
+  };
+
   return (
-    <>
+    <StyledContainer>
       {header}
       <AntdSelect
         aria-label={ariaLabel || name}
-        dropdownRender={(
-          originNode: ReactElement & { ref?: RefObject<HTMLElement> },
-        ) => {
-          if (!isDropdownVisible) {
-            originNode.ref?.current?.scrollTo({ top: 0 });
-          }
-          return <DropdownContent content={originNode} error={error} />;
-        }}
+        dropdownRender={dropdownRender}
         filterOption={handleFilterOption as any}
         getPopupContainer={triggerNode => triggerNode.parentNode}
         loading={isLoading}
@@ -339,17 +359,11 @@ const SelectComponent = ({
         showSearch={shouldShowSearch}
         tokenSeparators={TOKEN_SEPARATORS}
         value={selectValue}
+        style={{ width: '100%' }}
         {...props}
       />
-    </>
+    </StyledContainer>
   );
 };
 
-const Select = styled((
-  // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  { ...props }: SelectProps,
-) => <SelectComponent {...props} />)`
-  width: 100%;
-`;
-
 export default Select;
diff --git a/superset-frontend/src/components/Select/utils.ts b/superset-frontend/src/components/Select/utils.ts
index 1195cd6..18f1e16 100644
--- a/superset-frontend/src/components/Select/utils.ts
+++ b/superset-frontend/src/components/Select/utils.ts
@@ -23,7 +23,7 @@ import {
   GroupedOptionsType,
 } from 'react-select';
 
-import { OptionsType as AntdOptionsType } from './AntdSelect';
+import { OptionsType as AntdOptionsType } from './Select';
 
 /**
  * Find Option value that matches a possibly string value.
diff --git a/superset-frontend/src/components/index.ts b/superset-frontend/src/components/index.ts
index 02a919d..596fbb4 100644
--- a/superset-frontend/src/components/index.ts
+++ b/superset-frontend/src/components/index.ts
@@ -17,4 +17,4 @@
  * under the License.
  */
 
-export { default as Select } from './Select/AntdSelect';
+export { default as Select } from './Select/Select';