This is an automated email from the ASF dual-hosted git repository. suddjian pushed a commit to branch native-filters-fast-follow in repository https://gitbox.apache.org/repos/asf/incubator-superset.git
commit 26094dea0c77c8997802814eb17a966423913b12 Author: David Aaron Suddjian <[email protected]> AuthorDate: Fri Dec 4 11:04:20 2020 -0800 sleeker filter removal UX --- superset-frontend/package-lock.json | 68 +++++++ superset-frontend/package.json | 3 +- .../components/nativeFilters/FilterConfigForm.tsx | 13 +- .../components/nativeFilters/FilterConfigModal.tsx | 205 +++++++++++++++++---- 4 files changed, 246 insertions(+), 43 deletions(-) diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 7416c5c..ed7d513 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -4216,6 +4216,69 @@ "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==" }, + "@emotion/react": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.1.1.tgz", + "integrity": "sha512-otA0Np8OnOeU9ChkOS9iuLB6vIxiM+bJiU0id33CsQn3R2Pk9ijVHnxevENIKV/P2S7AhrD8cFbUGysEciWlEA==", + "requires": { + "@babel/runtime": "^7.7.2", + "@emotion/cache": "^11.0.0", + "@emotion/serialize": "^1.0.0", + "@emotion/sheet": "^1.0.0", + "@emotion/utils": "^1.0.0", + "@emotion/weak-memoize": "^0.2.5", + "hoist-non-react-statics": "^3.3.1" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.12.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.5.tgz", + "integrity": "sha512-plcc+hbExy3McchJCEQG3knOsuh3HH+Prx1P6cLIkET/0dLuQDEnrT+s27Axgc9bqfsmNUNHfscgMUdBpC9xfg==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + }, + "@emotion/cache": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.0.0.tgz", + "integrity": "sha512-NStfcnLkL5vj3mBILvkR2m/5vFxo3G0QEreYKDGHNHm9IMYoT/t3j6xwjx6lMI/S1LUJfVHQqn0m9wSINttTTQ==", + "requires": { + "@emotion/memoize": "^0.7.4", + "@emotion/sheet": "^1.0.0", + "@emotion/utils": "^1.0.0", + "@emotion/weak-memoize": "^0.2.5", + "stylis": "^4.0.3" + } + }, + "@emotion/serialize": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.0.0.tgz", + "integrity": "sha512-zt1gm4rhdo5Sry8QpCOpopIUIKU+mUSpV9WNmFILUraatm5dttNEaYzUWWSboSMUE6PtN2j1cAsuvcugfdI3mw==", + "requires": { + "@emotion/hash": "^0.8.0", + "@emotion/memoize": "^0.7.4", + "@emotion/unitless": "^0.7.5", + "@emotion/utils": "^1.0.0", + "csstype": "^3.0.2" + } + }, + "@emotion/sheet": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.0.0.tgz", + "integrity": "sha512-cdCHfZtf/0rahPDCZ9zyq+36EqfD/6c0WUqTFZ/hv9xadTUv2lGE5QK7/Z6Dnx2oRxC0usfVM2/BYn9q9B9wZA==" + }, + "@emotion/utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.0.0.tgz", + "integrity": "sha512-mQC2b3XLDs6QCW+pDQDiyO/EdGZYOygE8s5N5rrzjSI4M3IejPE/JPndCBwRT9z982aqQNi6beWs1UeayrQxxA==" + }, + "csstype": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.5.tgz", + "integrity": "sha512-uVDi8LpBUKQj6sdxNaTetL6FpeCqTjOvAQuQUa/qAqq8oOd4ivkbhgnqayl0dnPal8Tb/yB1tF+gOvCBiicaiQ==" + } + } + }, "@emotion/serialize": { "version": "0.11.16", "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-0.11.16.tgz", @@ -51650,6 +51713,11 @@ } } }, + "stylis": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.0.6.tgz", + "integrity": "sha512-1igcUEmYFBEO14uQHAJhCUelTR5jPztfdVKrYxRnDa5D5Dn3w0NxXupJNPr/VV/yRfZYEAco8sTIRZzH3sRYKg==" + }, "supercluster": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-4.1.1.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 80603bb..aed5dcc 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -65,6 +65,7 @@ "@babel/runtime-corejs3": "^7.8.4", "@data-ui/sparkline": "^0.0.84", "@emotion/core": "^10.0.35", + "@emotion/react": "^11.1.1", "@superset-ui/chart-controls": "^0.15.13", "@superset-ui/core": "^0.15.13", "@superset-ui/legacy-plugin-chart-calendar": "^0.15.13", @@ -92,8 +93,8 @@ "@superset-ui/plugin-chart-echarts": "^0.15.14", "@superset-ui/plugin-chart-table": "^0.15.13", "@superset-ui/plugin-chart-word-cloud": "^0.15.13", - "@superset-ui/preset-chart-xy": "^0.15.13", "@superset-ui/plugin-filter-antd": "../../superset-ui/plugins/plugin-filter-antd", + "@superset-ui/preset-chart-xy": "^0.15.13", "@types/react-sticky": "^6.0.3", "@vx/responsive": "^0.0.195", "abortcontroller-polyfill": "^1.1.9", diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigForm.tsx index 0411a51..f8d4d2c 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigForm.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigForm.tsx @@ -23,6 +23,7 @@ import SupersetResourceSelect, { Value, } from 'src/components/SupersetResourceSelect'; import { + Button, Checkbox, Form, Input, @@ -125,6 +126,7 @@ const ScopingTreeNote = styled.div` const RemovedContent = styled.div` display: flex; + flex-direction: column; height: 400px; // arbitrary text-align: center; justify-content: center; @@ -136,6 +138,7 @@ export interface FilterConfigFormProps { filterId: string; filterToEdit?: Filter; removed?: boolean; + restore: (filterId: string) => void; form: FormInstance; } @@ -143,6 +146,7 @@ export const FilterConfigForm: React.FC<FilterConfigFormProps> = ({ filterId, filterToEdit, removed, + restore, form, }) => { const [advancedScopingOpen, setAdvancedScopingOpen] = useState<Scoping>( @@ -157,9 +161,12 @@ export const FilterConfigForm: React.FC<FilterConfigFormProps> = ({ if (removed) { return ( <RemovedContent> - {t( - 'You have removed this filter. Click the trash again to bring it back.', - )} + <p>{t('You have removed this filter.')}</p> + <div> + <Button type="primary" onClick={() => restore(filterId)}> + {t('Restore Filter')} + </Button> + </div> </RemovedContent> ); } diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal.tsx index 167a21f..9518af5 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterConfigModal.tsx @@ -16,24 +16,27 @@ * specific language governing permissions and limitations * under the License. */ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useReducer, + useState, +} from 'react'; import { useDispatch } from 'react-redux'; +import { keyframes } from '@emotion/react'; import { findLastIndex, uniq } from 'lodash'; import shortid from 'shortid'; import { Store } from 'antd/lib/form/interface'; import { DeleteFilled } from '@ant-design/icons'; -import { styled, t } from '@superset-ui/core'; +import { css, styled, t } from '@superset-ui/core'; import { Button, Form } from 'src/common/components'; import Icon from 'src/components/Icon'; import { StyledModal } from 'src/common/components/Modal'; import { LineEditableTabs } from 'src/common/components/Tabs'; import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants'; import { usePrevious } from 'src/common/hooks/usePrevious'; -import { - useFilterConfigMap, - useFilterConfiguration, - useAllFilterState, -} from './state'; +import { useFilterConfigMap, useFilterConfiguration } from './state'; import FilterConfigForm from './FilterConfigForm'; import { Filter, @@ -43,6 +46,9 @@ import { Scoping, } from './types'; +// how long to show the "undo" button when removing a filter +const REMOVAL_DELAY_SECS = 5; + const StyledModalBody = styled.div` display: flex; flex-direction: row; @@ -52,14 +58,59 @@ const StyledModalBody = styled.div` } `; -const RemovedStatus = styled.span` +const StyledForm = styled(Form)` + width: 100%; +`; + +const FilterTabs = styled(LineEditableTabs)` + // extra selector specificity: + &.ant-tabs-card > .ant-tabs-nav .ant-tabs-tab { + min-width: 200px; + margin-left: 0; + padding: 0; + padding-bottom: ${({ theme }) => theme.gridUnit}px; + } + + .ant-tabs-tab-btn { + text-align: left; + justify-content: space-between; + text-transform: unset; + } +`; + +const tabTitleRemovalAnimation = keyframes` + 0%, 90% { + opacity: 1; + } + 95%, 100% { + opacity: 0; + } +`; + +const FilterTabTitle = styled.span` + transition: color ${({ theme }) => theme.transitionTiming}s; + width: 100%; + display: flex; + flex-direction: row; + justify-content: space-between; + &.removed { - text-decoration: line-through; + color: ${({ theme }) => theme.colors.warning.dark1}; + transform-origin: top; + animation: ${tabTitleRemovalAnimation} ${REMOVAL_DELAY_SECS}s; } `; +type FilterRemoval = + | null + | { + isPending: true; // the filter sticks around for a moment before removal is finalized + timerId: number; // id of the timer that finally removes the filter + } + | { isPending: false }; + function generateFilterId() { - return `FILTER_V2-${shortid.generate()}`; + return `NATIVE_FILTER-${shortid.generate()}`; } export interface FilterConfigModalProps { @@ -73,6 +124,12 @@ export interface FilterConfigModalProps { const getFilterIds = (config: FilterConfiguration) => config.map(filter => filter.id); +/** + * Modal for management of dashboard-native filters. + * Filters can be created, edited, and deleted. + * No changes are saved until the "save" button is pressed, + * at which time the updates will be batched. + */ export function FilterConfigModal({ isOpen, initialFilterId, @@ -82,18 +139,44 @@ export function FilterConfigModal({ }: FilterConfigModalProps) { const [form] = Form.useForm<NativeFiltersForm>(); + // the filter config from redux state, this does not change until modal is closed. const filterConfig = useFilterConfiguration(); const filterConfigMap = useFilterConfigMap(); - // new filter ids may belong to filters that do not exist yet + + // new filter ids belong to filters have been added during + // this configuration session, and only exist in the form state until we submit. const [newFilterIds, setNewFilterIds] = useState<string[]>([]); - // store ids of filters that have been removed but keep them around in the state - const [removedFilters, setRemovedFilters] = useState<Record<string, boolean>>( - {}, + + // store ids of filters that have been removed with the time they were removed + // so that we can disappear them after a few secs. + // filters are still kept in state until form is submitted. + const [removedFilters, setRemovedFilters] = useState< + Record<string, FilterRemoval> + >({}); + + // brings back a filter that was previously removed ("Undo") + const restoreFilter = useCallback( + (id: string) => { + const removal = removedFilters[id]; + // gotta clear the removal timeout to prevent the filter from getting deleted + if (removal?.isPending) clearTimeout(removal.timerId); + setRemovedFilters(current => ({ ...current, [id]: null })); + }, + [removedFilters], ); + + // The full ordered set of ((original + new) - completely removed) filter ids + // Use this as the canonical list of what filters are being configured! + // This includes filter ids that are pending removal, so check for that. const filterIds = useMemo( - () => uniq([...getFilterIds(filterConfig), ...newFilterIds]), - [filterConfig, newFilterIds], + () => + uniq([...getFilterIds(filterConfig), ...newFilterIds]).filter( + id => !removedFilters[id] || removedFilters[id]?.isPending, + ), + [filterConfig, newFilterIds, removedFilters], ); + + // open the first filter in the list to start const getInitialCurrentFilterId = useCallback( () => initialFilterId ?? filterIds[0], [initialFilterId, filterIds], @@ -101,24 +184,45 @@ export function FilterConfigModal({ const [currentFilterId, setCurrentFilterId] = useState( getInitialCurrentFilterId, ); + // the form values are managed by the antd form, but we copy them to here + // so that we can display them (e.g. filter titles in the tab headers) const [formValues, setFormValues] = useState<NativeFiltersForm>({ filters: {}, }); + const wasOpen = usePrevious(isOpen); + useEffect(() => { + // if the currently viewed filter is fully removed, change to another tab + const currentFilterRemoved = removedFilters[currentFilterId]; + if (currentFilterRemoved && !currentFilterRemoved.isPending) { + const nextFilterIndex = findLastIndex( + filterIds, + id => !removedFilters[id] && id !== currentFilterId, + ); + if (nextFilterIndex !== -1) + setCurrentFilterId(filterIds[nextFilterIndex]); + } + }, [currentFilterId, removedFilters, filterIds]); + + // generates a new filter id and appends it to the newFilterIds const addFilter = useCallback(() => { const newFilterId = generateFilterId(); setNewFilterIds([...newFilterIds, newFilterId]); setCurrentFilterId(newFilterId); }, [newFilterIds, setCurrentFilterId]); + // if this is a "create" modal rather than an "edit" modal, + // add a filter on modal open useEffect(() => { if (createNewOnOpen && isOpen && !wasOpen) { addFilter(); } }, [createNewOnOpen, isOpen, wasOpen, addFilter]); + // After this, it should be as if the modal was just opened fresh. + // Called when the modal is closed. const resetForm = useCallback(() => { form.resetFields(); setNewFilterIds([]); @@ -126,22 +230,28 @@ export function FilterConfigModal({ setRemovedFilters({}); }, [form, getInitialCurrentFilterId]); + const completeFilterRemoval = (filterId: string) => { + // the filter state will actually stick around in the form, + // and the filterConfig/newFilterIds, but we use removedFilters + // to mark it as removed. + setRemovedFilters(removedFilters => ({ + ...removedFilters, + [filterId]: { isPending: false }, + })); + }; + function onTabEdit(filterId: string, action: 'add' | 'remove') { if (action === 'remove') { - setRemovedFilters({ + // first set up the timer to completely remove it + const timerId = window.setTimeout( + () => completeFilterRemoval(filterId), + REMOVAL_DELAY_SECS * 1000, + ); + // mark the filter state as "removal in progress" + setRemovedFilters(removedFilters => ({ ...removedFilters, - // trash can button is actually a toggle - [filterId]: !removedFilters[filterId], - }); - if (filterId === currentFilterId && !removedFilters[filterId]) { - // when a filter is removed, switch the view to a non-removed one - const lastNotRemoved = findLastIndex( - filterIds, - id => !removedFilters[id] && id !== filterId, - ); - if (lastNotRemoved !== -1) - setCurrentFilterId(filterIds[lastNotRemoved]); - } + [filterId]: { isPending: true, timerId }, + })); } else if (action === 'add') { addFilter(); } @@ -167,7 +277,12 @@ export function FilterConfigModal({ // filter id is the second item in the field name if (!errorFields.some(field => field.name[1] === currentFilterId)) { // switch to the first tab that had a validation error - setCurrentFilterId(errorFields[0].name[1]); + const filterError = errorFields.find( + field => field.name[0] === 'filters', + ); + if (filterError) { + setCurrentFilterId(filterError.name[1]); + } } return null; } @@ -233,9 +348,9 @@ export function FilterConfigModal({ centered > <StyledModalBody> - <Form + <StyledForm form={form} - onValuesChange={(changes, values) => { + onValuesChange={(changes, values: NativeFiltersForm) => { if ( changes.filters && Object.values(changes.filters).some( @@ -247,7 +362,7 @@ export function FilterConfigModal({ } }} > - <LineEditableTabs + <FilterTabs tabPosition="left" onChange={setCurrentFilterId} activeKey={currentFilterId} @@ -256,25 +371,37 @@ export function FilterConfigModal({ {filterIds.map(id => ( <LineEditableTabs.TabPane tab={ - <RemovedStatus + <FilterTabTitle className={removedFilters[id] ? 'removed' : ''} > - {getFilterTitle(id)} - </RemovedStatus> + <div> + {removedFilters[id] ? t('(Removed)') : getFilterTitle(id)} + </div> + {removedFilters[id] && ( + <a + role="button" + tabIndex={0} + onClick={() => restoreFilter(id)} + > + {t('Undo?')} + </a> + )} + </FilterTabTitle> } key={id} - closeIcon={<DeleteFilled />} + closeIcon={removedFilters[id] ? <></> : <DeleteFilled />} > <FilterConfigForm form={form} filterId={id} filterToEdit={filterConfigMap[id]} removed={!!removedFilters[id]} + restore={restoreFilter} /> </LineEditableTabs.TabPane> ))} - </LineEditableTabs> - </Form> + </FilterTabs> + </StyledForm> </StyledModalBody> </StyledModal> );
