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';