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

Reply via email to