You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by su...@apache.org on 2020/12/07 20:23:45 UTC
[incubator-superset] 06/07: sleeker filter removal UX
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 <aa...@gmail.com>
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>
);