From 4630abb5a891fa40962b58a11445b73570b1fa20 Mon Sep 17 00:00:00 2001 From: Ville Brofeldt <33317356+villebro@users.noreply.github.com> Date: Tue, 29 Jun 2021 18:57:49 +0300 Subject: [PATCH] feat(native-filters): add support for preselect filters (#15427) * feat(native-filters): add support for sharing preselected filters * abc * add serialization --- .../dashboard/util/getDashboardUrl_spec.js | 45 +++++++++++++++---- .../src/components/AnchorLink/index.jsx | 8 ++-- superset-frontend/src/constants.ts | 8 ++++ .../src/dashboard/actions/hydrate.js | 1 + .../components/DashboardBuilder/state.ts | 7 ++- .../Header/HeaderActionsDropdown/index.jsx | 27 ++++++----- .../src/dashboard/components/Header/index.jsx | 3 ++ .../components/SliceHeaderControls/index.tsx | 10 ++--- .../FilterBar/FilterControls/FilterValue.tsx | 10 ++++- .../nativeFilters/FilterBar/index.tsx | 31 ++++++++++++- .../nativeFilters/FilterBar/state.ts | 16 ++++++- .../components/nativeFilters/state.ts | 11 +++++ .../dashboard/containers/DashboardHeader.jsx | 4 ++ superset-frontend/src/dashboard/types.ts | 6 ++- .../dashboard/util/activeDashboardFilters.js | 4 +- .../src/dashboard/util/getDashboardUrl.ts | 36 ++++++++++++--- superset-frontend/src/utils/urlUtils.ts | 13 +++++- 17 files changed, 199 insertions(+), 41 deletions(-) diff --git a/superset-frontend/spec/javascripts/dashboard/util/getDashboardUrl_spec.js b/superset-frontend/spec/javascripts/dashboard/util/getDashboardUrl_spec.js index 77a19c771..c986c6fb4 100644 --- a/superset-frontend/spec/javascripts/dashboard/util/getDashboardUrl_spec.js +++ b/superset-frontend/spec/javascripts/dashboard/util/getDashboardUrl_spec.js @@ -34,35 +34,64 @@ describe('getChartIdsFromLayout', () => { }); it('should encode filters', () => { - const url = getDashboardUrl('path', filters); + const url = getDashboardUrl({ pathname: 'path', filters }); expect(url).toBe( 'path?preselect_filters=%7B%2235%22%3A%7B%22key%22%3A%5B%22value%22%5D%7D%7D', ); }); it('should encode filters with hash', () => { - const urlWithHash = getDashboardUrl('path', filters, 'iamhashtag'); + const urlWithHash = getDashboardUrl({ + pathname: 'path', + filters, + hash: 'iamhashtag', + }); expect(urlWithHash).toBe( 'path?preselect_filters=%7B%2235%22%3A%7B%22key%22%3A%5B%22value%22%5D%7D%7D#iamhashtag', ); }); it('should encode filters with standalone', () => { - const urlWithStandalone = getDashboardUrl( - 'path', + const urlWithStandalone = getDashboardUrl({ + pathname: 'path', filters, - '', - DashboardStandaloneMode.HIDE_NAV, - ); + standalone: DashboardStandaloneMode.HIDE_NAV, + }); expect(urlWithStandalone).toBe( `path?preselect_filters=%7B%2235%22%3A%7B%22key%22%3A%5B%22value%22%5D%7D%7D&standalone=${DashboardStandaloneMode.HIDE_NAV}`, ); }); it('should encode filters with missing standalone', () => { - const urlWithStandalone = getDashboardUrl('path', filters, '', null); + const urlWithStandalone = getDashboardUrl({ + pathname: 'path', + filters, + standalone: null, + }); expect(urlWithStandalone).toBe( 'path?preselect_filters=%7B%2235%22%3A%7B%22key%22%3A%5B%22value%22%5D%7D%7D', ); }); + + it('should encode native filters', () => { + const urlWithNativeFilters = getDashboardUrl({ + pathname: 'path', + dataMask: { + 'NATIVE_FILTER-foo123': { + filterState: { + label: 'custom label', + value: ['a', 'b'], + }, + }, + 'NATIVE_FILTER-bar456': { + filterState: { + value: undefined, + }, + }, + }, + }); + expect(urlWithNativeFilters).toBe( + 'path?preselect_filters=%7B%7D&native_filters=%28NATIVE_FILTER-bar456%3A%21n%2CNATIVE_FILTER-foo123%3A%21%28a%2Cb%29%29', + ); + }); }); diff --git a/superset-frontend/src/components/AnchorLink/index.jsx b/superset-frontend/src/components/AnchorLink/index.jsx index 42f93c353..16be622bd 100644 --- a/superset-frontend/src/components/AnchorLink/index.jsx +++ b/superset-frontend/src/components/AnchorLink/index.jsx @@ -80,11 +80,11 @@ class AnchorLink extends React.PureComponent { {showShortLinkButton && ( ( dashboardFilters, nativeFilters, dashboardState: { + preselectNativeFilters: getUrlParam(URL_PARAMS.nativeFilters), sliceIds: Array.from(sliceIds), directPathToChild, directPathLastUpdated: Date.now(), diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts b/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts index 874525d93..e2d3b2790 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/state.ts @@ -17,6 +17,7 @@ * under the License. */ import { useSelector } from 'react-redux'; +import { JsonObject } from '@superset-ui/core'; import { FeatureFlag, isFeatureEnabled } from 'src/featureFlags'; import { useEffect, useState } from 'react'; import { URL_PARAMS } from 'src/constants'; @@ -37,6 +38,9 @@ export const useNativeFilters = () => { const showNativeFilters = useSelector( state => state.dashboardInfo.metadata?.show_native_filters, ); + const preselectNativeFilters = useSelector( + state => state.dashboardState?.preselectNativeFilters || {}, + ); const canEdit = useSelector( ({ dashboardInfo }) => dashboardInfo.dash_edit_perm, ); @@ -50,7 +54,7 @@ export const useNativeFilters = () => { (canEdit || (!canEdit && filterValues.length !== 0)); const requiredFirstFilter = filterValues.filter( - ({ requiredFirst }) => requiredFirst, + filter => filter.requiredFirst || preselectNativeFilters[filter.id], ); const dataMask = useNativeFiltersDataMask(); const showDashboard = @@ -89,5 +93,6 @@ export const useNativeFilters = () => { dashboardFiltersOpen, toggleDashboardFiltersOpen, nativeFiltersEnabled, + preselectNativeFilters, }; }; diff --git a/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx b/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx index 4f487972e..e69d4a147 100644 --- a/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx +++ b/superset-frontend/src/dashboard/components/Header/HeaderActionsDropdown/index.jsx @@ -42,6 +42,7 @@ const propTypes = { dashboardInfo: PropTypes.object.isRequired, dashboardId: PropTypes.number.isRequired, dashboardTitle: PropTypes.string.isRequired, + dataMask: PropTypes.object.isRequired, customCss: PropTypes.string.isRequired, colorNamespace: PropTypes.string, colorScheme: PropTypes.string, @@ -164,12 +165,13 @@ class HeaderActionsDropdown extends React.PureComponent { break; } case MENU_KEYS.TOGGLE_FULLSCREEN: { - const url = getDashboardUrl( - window.location.pathname, - getActiveFilters(), - window.location.hash, - !getUrlParam(URL_PARAMS.standalone), - ); + const url = getDashboardUrl({ + dataMask: this.props.dataMask, + pathname: window.location.pathname, + filters: getActiveFilters(), + hash: window.location.hash, + standalone: !getUrlParam(URL_PARAMS.standalone), + }); window.location.replace(url); break; } @@ -183,6 +185,7 @@ class HeaderActionsDropdown extends React.PureComponent { dashboardTitle, dashboardId, dashboardInfo, + dataMask, refreshFrequency, shouldPersistRefreshFrequency, editMode, @@ -206,11 +209,13 @@ class HeaderActionsDropdown extends React.PureComponent { const emailTitle = t('Superset dashboard'); const emailSubject = `${emailTitle} ${dashboardTitle}`; const emailBody = t('Check out this dashboard: '); - const url = getDashboardUrl( - window.location.pathname, - getActiveFilters(), - window.location.hash, - ); + + const url = getDashboardUrl({ + dataMask, + pathname: window.location.pathname, + filters: getActiveFilters(), + hash: window.location.hash, + }); const menu = ( { {supersetCanShare && ( = ({ const { name: groupby } = column; const hasDataSource = !!datasetId; const [isLoading, setIsLoading] = useState(hasDataSource); - const [isRefreshing, setIsRefreshing] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(true); + const preselection = usePreselectNativeFilter(filter.id); const dispatch = useDispatch(); useEffect(() => { @@ -195,6 +197,10 @@ const FilterValue: React.FC = ({ /> ); } + const filterState = { ...filter.dataMask?.filterState }; + if (filterState.value === undefined && preselection) { + filterState.value = preselection; + } return ( @@ -209,7 +215,7 @@ const FilterValue: React.FC = ({ queriesData={hasDataSource ? state : [{ data: [{}] }]} chartType={filterType} behaviors={[Behavior.NATIVE_FILTER]} - filterState={{ ...filter.dataMask?.filterState }} + filterState={filterState} ownState={filter.dataMask?.ownState} enableNoResults={metadata?.enableNoResults} isRefreshing={isRefreshing} diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx index 1ec877365..f7d528c4c 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/index.tsx @@ -33,6 +33,7 @@ import { testWithId } from 'src/utils/testUtils'; import { Filter } from 'src/dashboard/components/nativeFilters/types'; import Loading from 'src/components/Loading'; import { getInitialDataMask } from 'src/dataMask/reducer'; +import { areObjectsEqual } from 'src/reduxUtils'; import { checkIsApplyDisabled, TabIds } from './utils'; import FilterSets from './FilterSets'; import { @@ -45,6 +46,7 @@ import { import EditSection from './FilterSets/EditSection'; import Header from './Header'; import FilterControls from './FilterControls/FilterControls'; +import { usePreselectNativeFilters } from '../state'; export const FILTER_BAR_TEST_ID = 'filter-bar'; export const getFilterBarTestId = testWithId(FILTER_BAR_TEST_ID); @@ -156,6 +158,8 @@ const FilterBar: React.FC = ({ const filterValues = Object.values(filters); const dataMaskApplied: DataMaskStateWithId = useNativeFiltersDataMask(); const [isFilterSetChanged, setIsFilterSetChanged] = useState(false); + const preselectNativeFilters = usePreselectNativeFilters(); + const [initializedFilters, setInitializedFilters] = useState([]); useEffect(() => { setDataMaskSelected(() => dataMaskApplied); @@ -185,8 +189,33 @@ const FilterBar: React.FC = ({ ) => { setIsFilterSetChanged(tab !== TabIds.AllFilters); setDataMaskSelected(draft => { - // force instant updating on initialization for filters with `requiredFirst` is true or instant filters + // check if a filter has preselect filters if ( + preselectNativeFilters?.[filter.id] !== undefined && + !initializedFilters.includes(filter.id) + ) { + /** + * since preselect filters don't have extraFormData, they need to iterate + * a few times to populate the full state necessary for proper filtering. + * Once both filterState and extraFormData are identical, we can coclude + * that the filter has been fully initialized. + */ + if ( + areObjectsEqual( + dataMask.filterState, + dataMaskSelected[filter.id]?.filterState, + ) && + areObjectsEqual( + dataMask.extraFormData, + dataMaskSelected[filter.id]?.extraFormData, + ) + ) { + setInitializedFilters(prevState => [...prevState, filter.id]); + } + dispatch(updateDataMask(filter.id, dataMask)); + } + // force instant updating on initialization for filters with `requiredFirst` is true or instant filters + else if ( (dataMaskSelected[filter.id] && filter.isInstant) || // filterState.value === undefined - means that value not initialized (dataMask.filterState?.value !== undefined && diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts index aa4894fd9..12d7b6c19 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/state.ts @@ -30,6 +30,7 @@ import { import { useEffect, useState } from 'react'; import { ChartsState, RootState } from 'src/dashboard/types'; import { NATIVE_FILTER_PREFIX } from '../FiltersConfigModal/utils'; +import { Filter } from '../types'; export const useFilterSets = () => useSelector( @@ -37,7 +38,20 @@ export const useFilterSets = () => ); export const useFilters = () => - useSelector(state => state.nativeFilters.filters); + useSelector(state => { + const preselectNativeFilters = + state.dashboardState?.preselectNativeFilters || {}; + return Object.entries(state.nativeFilters.filters).reduce( + (acc, [filterId, filter]: [string, Filter]) => ({ + ...acc, + [filterId]: { + ...filter, + preselect: preselectNativeFilters[filterId], + }, + }), + {} as Filters, + ); + }); export const useNativeFiltersDataMask = () => { const dataMask = useSelector( diff --git a/superset-frontend/src/dashboard/components/nativeFilters/state.ts b/superset-frontend/src/dashboard/components/nativeFilters/state.ts index 280a203da..165ee0f8d 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/state.ts +++ b/superset-frontend/src/dashboard/components/nativeFilters/state.ts @@ -18,6 +18,7 @@ */ import { useSelector } from 'react-redux'; import { useMemo } from 'react'; +import { JsonObject } from '@superset-ui/core'; import { Filter, FilterConfiguration } from './types'; import { ActiveTabs, DashboardLayout, RootState } from '../../types'; import { TAB_TYPE } from '../../util/componentTypes'; @@ -124,3 +125,13 @@ export function useSelectFiltersInScope(cascadeFilters: CascadeFilter[]) { return [filtersInScope, filtersOutOfScope]; }, [cascadeFilters, dashboardHasTabs, isFilterInScope]); } + +export function usePreselectNativeFilters(): JsonObject | undefined { + return useSelector( + state => state.dashboardState?.preselectNativeFilters, + ); +} + +export function usePreselectNativeFilter(id: string): JsonObject | undefined { + return usePreselectNativeFilters()?.[id]; +} diff --git a/superset-frontend/src/dashboard/containers/DashboardHeader.jsx b/superset-frontend/src/dashboard/containers/DashboardHeader.jsx index 6351561c7..2322ca734 100644 --- a/superset-frontend/src/dashboard/containers/DashboardHeader.jsx +++ b/superset-frontend/src/dashboard/containers/DashboardHeader.jsx @@ -19,6 +19,7 @@ import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; +import { updateDataMask } from 'src/dataMask/actions'; import DashboardHeader from '../components/Header'; import isDashboardLoading from '../util/isDashboardLoading'; @@ -61,6 +62,7 @@ function mapStateToProps({ dashboardState, dashboardInfo, charts, + dataMask, }) { return { dashboardInfo, @@ -77,6 +79,7 @@ function mapStateToProps({ colorNamespace: dashboardState.colorNamespace, colorScheme: dashboardState.colorScheme, charts, + dataMask, userId: dashboardInfo.userId, isStarred: !!dashboardState.isStarred, isPublished: !!dashboardState.isPublished, @@ -118,6 +121,7 @@ function mapDispatchToProps(dispatch) { setRefreshFrequency, dashboardInfoChanged, dashboardTitleChanged, + updateDataMask, }, dispatch, ); diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts index 7c1edae96..7c70ff43c 100644 --- a/superset-frontend/src/dashboard/types.ts +++ b/superset-frontend/src/dashboard/types.ts @@ -52,6 +52,7 @@ export type ActiveTabs = string[]; export type DashboardLayout = { [key: string]: LayoutItem }; export type DashboardLayoutState = { present: DashboardLayout }; export type DashboardState = { + preselectNativeFilters?: JsonObject; editMode: boolean; directPathToChild: string[]; activeTabs: ActiveTabs; @@ -63,7 +64,10 @@ export type DashboardInfo = { }; userId: string; dash_edit_perm: boolean; - metadata: { show_native_filters: boolean; chart_configuration: JsonObject }; + metadata: { + show_native_filters: boolean; + chart_configuration: JsonObject; + }; }; export type ChartsState = { [key: string]: Chart }; diff --git a/superset-frontend/src/dashboard/util/activeDashboardFilters.js b/superset-frontend/src/dashboard/util/activeDashboardFilters.js index 30bdc2540..96db2c312 100644 --- a/superset-frontend/src/dashboard/util/activeDashboardFilters.js +++ b/superset-frontend/src/dashboard/util/activeDashboardFilters.js @@ -33,7 +33,9 @@ let allComponents = {}; // output: { [id_column]: { values, scope } } export function getActiveFilters() { - return activeFilters; + return { + ...activeFilters, + }; } // currently filter_box is a chart, diff --git a/superset-frontend/src/dashboard/util/getDashboardUrl.ts b/superset-frontend/src/dashboard/util/getDashboardUrl.ts index 7eb817a09..53eb826d4 100644 --- a/superset-frontend/src/dashboard/util/getDashboardUrl.ts +++ b/superset-frontend/src/dashboard/util/getDashboardUrl.ts @@ -16,15 +16,25 @@ * specific language governing permissions and limitations * under the License. */ +import rison from 'rison'; +import { JsonObject } from '@superset-ui/core'; import { URL_PARAMS } from 'src/constants'; import serializeActiveFilterValues from './serializeActiveFilterValues'; +import { DataMaskState } from '../../dataMask/types'; -export default function getDashboardUrl( - pathname: string, +export default function getDashboardUrl({ + pathname, filters = {}, hash = '', - standalone?: number | null, -) { + standalone, + dataMask, +}: { + pathname: string; + filters: JsonObject; + hash: string; + standalone?: number | null; + dataMask?: DataMaskState; +}) { const newSearchParams = new URLSearchParams(); // convert flattened { [id_column]: values } object @@ -38,7 +48,23 @@ export default function getDashboardUrl( newSearchParams.set(URL_PARAMS.standalone.name, standalone.toString()); } - const hashSection = hash ? `#${hash}` : ''; + if (dataMask) { + const filterStates = Object.entries(dataMask).reduce( + (agg, [key, value]) => { + const filterState = value?.filterState?.value; + return { + ...agg, + [key]: filterState || null, + }; + }, + {}, + ); + newSearchParams.set( + URL_PARAMS.nativeFilters.name, + rison.encode(filterStates), + ); + } + const hashSection = hash ? `#${hash}` : ''; return `${pathname}?${newSearchParams.toString()}${hashSection}`; } diff --git a/superset-frontend/src/utils/urlUtils.ts b/superset-frontend/src/utils/urlUtils.ts index ebb3a1df8..f0d9fa8e6 100644 --- a/superset-frontend/src/utils/urlUtils.ts +++ b/superset-frontend/src/utils/urlUtils.ts @@ -17,15 +17,17 @@ * under the License. */ import { SupersetClient } from '@superset-ui/core'; +import rison from 'rison'; import { getClientErrorObject } from './getClientErrorObject'; import { URL_PARAMS } from '../constants'; -export type UrlParamType = 'string' | 'number' | 'boolean' | 'object'; +export type UrlParamType = 'string' | 'number' | 'boolean' | 'object' | 'rison'; export type UrlParam = typeof URL_PARAMS[keyof typeof URL_PARAMS]; export function getUrlParam(param: UrlParam & { type: 'string' }): string; export function getUrlParam(param: UrlParam & { type: 'number' }): number; export function getUrlParam(param: UrlParam & { type: 'boolean' }): boolean; export function getUrlParam(param: UrlParam & { type: 'object' }): object; +export function getUrlParam(param: UrlParam & { type: 'rison' }): object; export function getUrlParam({ name, type }: UrlParam): unknown { const urlParam = new URLSearchParams(window.location.search).get(name); switch (type) { @@ -53,6 +55,15 @@ export function getUrlParam({ name, type }: UrlParam): unknown { return null; } return urlParam !== 'false' && urlParam !== '0'; + case 'rison': + if (!urlParam) { + return null; + } + try { + return rison.decode(urlParam); + } catch { + return null; + } default: return urlParam; }