feat(dashboard): add Rison-encoded URL filter support (#39795)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Hugh A. Miles II
2026-05-31 08:06:09 -04:00
committed by GitHub
parent 7e8b8e25a5
commit 9a79588d35
15 changed files with 2278 additions and 16 deletions

View File

@@ -17,20 +17,39 @@
* under the License.
*/
import { FC, memo, useMemo } from 'react';
import { FC, memo, useCallback, useEffect, useMemo, useState } from 'react';
import { t } from '@apache-superset/core/translation';
import { DataMaskStateWithId } from '@superset-ui/core';
import {
DataMaskStateWithId,
QueryObjectFilterClause,
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { Loading } from '@superset-ui/core/components';
import { RootState } from 'src/dashboard/types';
import { Icons } from '@superset-ui/core/components/Icons';
import { FilterBarOrientation, RootState } from 'src/dashboard/types';
import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { removeDataMask, updateDataMask } from 'src/dataMask/actions';
import {
getRisonFilterParam,
parseRisonFilters,
RISON_UNMATCHED_DATAMASK_ID,
risonFiltersToExtraFormDataFilters,
updateUrlWithUnmatchedFilters,
} from 'src/dashboard/util/risonFilters';
import FilterControls from './FilterControls/FilterControls';
import { useChartsVerboseMaps, getFilterBarTestId } from './utils';
import { HorizontalBarProps } from './types';
import FilterBarSettings from './FilterBarSettings';
import crossFiltersSelector from './CrossFilters/selectors';
import {
getUrlFilterIndicators,
getUrlFilterIdentity,
UrlFilterIndicator,
} from './UrlFilters/urlFilterUtils';
import UrlFilterTag from './UrlFilters/UrlFilterTag';
const HorizontalBar = styled.div`
${({ theme }) => `
@@ -65,6 +84,28 @@ const FilterBarEmptyStateContainer = styled.div`
`}
`;
const UrlFiltersContainer = styled.div`
${({ theme }) => `
display: flex;
flex-direction: row;
align-items: center;
gap: ${theme.sizeUnit * 2}px;
padding: 0 ${theme.sizeUnit * 2}px;
margin-right: ${theme.sizeUnit * 2}px;
border-right: 1px solid ${theme.colorBorder};
`}
`;
const UrlFilterTitle = styled.div`
${({ theme }) => `
display: flex;
align-items: center;
gap: ${theme.sizeUnit}px;
font-weight: ${theme.fontWeightStrong};
font-size: ${theme.fontSizeSM}px;
`}
`;
const HorizontalFilterBar: FC<HorizontalBarProps> = ({
actions,
dataMaskSelected,
@@ -79,6 +120,9 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
const dataMask = useSelector<RootState, DataMaskStateWithId>(
state => state.dataMask,
);
const dispatch = useDispatch();
const history = useHistory();
const location = useLocation();
const chartIds = useChartIds();
const chartLayoutItems = useChartLayoutItems();
const verboseMaps = useChartsVerboseMaps();
@@ -94,9 +138,71 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
[chartIds, chartLayoutItems, dataMask, verboseMaps],
);
const [activeUrlFilters, setActiveUrlFilters] = useState<
UrlFilterIndicator[]
>(() => getUrlFilterIndicators());
// Re-read chips whenever the URL changes (back/forward navigation, or a
// programmatic history.replace).
useEffect(() => {
setActiveUrlFilters(getUrlFilterIndicators());
}, [location.search]);
const handleRemoveUrlFilter = useCallback(
(filterToRemove: UrlFilterIndicator) => {
const risonParam = getRisonFilterParam();
if (!risonParam) return;
const removeId = getUrlFilterIdentity(filterToRemove.filter);
const currentFilters = parseRisonFilters(risonParam);
const remaining = currentFilters.filter(
f => getUrlFilterIdentity(f) !== removeId,
);
updateUrlWithUnmatchedFilters(remaining, history);
setActiveUrlFilters(prev =>
prev.filter(f => getUrlFilterIdentity(f.filter) !== removeId),
);
if (remaining.length === 0) {
dispatch(removeDataMask(RISON_UNMATCHED_DATAMASK_ID));
} else {
const extraFormDataFilters: QueryObjectFilterClause[] =
risonFiltersToExtraFormDataFilters(remaining);
dispatch(
updateDataMask(RISON_UNMATCHED_DATAMASK_ID, {
extraFormData: { filters: extraFormDataFilters },
}),
);
}
},
[dispatch, history],
);
const urlFiltersComponent = useMemo(() => {
if (activeUrlFilters.length === 0) return null;
return (
<UrlFiltersContainer>
<UrlFilterTitle>
<Icons.LinkOutlined iconSize="s" />
{t('URL Filters')}
</UrlFilterTitle>
{activeUrlFilters.map(filter => (
<UrlFilterTag
key={getUrlFilterIdentity(filter.filter)}
filter={filter}
orientation={FilterBarOrientation.Horizontal}
onRemove={handleRemoveUrlFilter}
/>
))}
</UrlFiltersContainer>
);
}, [activeUrlFilters, handleRemoveUrlFilter]);
const hasFilters =
filterValues.length > 0 ||
selectedCrossFilters.length > 0 ||
activeUrlFilters.length > 0 ||
chartCustomizationValues.length > 0;
return (
@@ -113,16 +219,19 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
</FilterBarEmptyStateContainer>
)}
{hasFilters && (
<FilterControls
dataMaskSelected={dataMaskSelected}
onFilterSelectionChange={onSelectionChange}
onPendingCustomizationDataMaskChange={
onPendingCustomizationDataMaskChange
}
chartCustomizationValues={chartCustomizationValues}
clearAllTriggers={clearAllTriggers}
onClearAllComplete={onClearAllComplete}
/>
<>
{urlFiltersComponent}
<FilterControls
dataMaskSelected={dataMaskSelected}
onFilterSelectionChange={onSelectionChange}
onPendingCustomizationDataMaskChange={
onPendingCustomizationDataMaskChange
}
chartCustomizationValues={chartCustomizationValues}
clearAllTriggers={clearAllTriggers}
onClearAllComplete={onClearAllComplete}
/>
</>
)}
{actions}
</>

View File

@@ -36,6 +36,7 @@ const renderWrapper = (overrideProps?: Record<string, any>) =>
waitFor(() =>
render(<HorizontalBar {...defaultProps} {...overrideProps} />, {
useRedux: true,
useRouter: true,
initialState: {
dashboardState: {
sliceIds: [],

View File

@@ -0,0 +1,84 @@
/**
* 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 { useCSSTextTruncation } from '@superset-ui/core';
import { styled, css, useTheme } from '@apache-superset/core/theme';
import { Tag } from 'src/components/Tag';
import { Tooltip } from '@superset-ui/core/components';
import { FilterBarOrientation } from 'src/dashboard/types';
import { ellipsisCss } from '../CrossFilters/styles';
import { UrlFilterIndicator } from './urlFilterUtils';
const StyledValue = styled.b`
${({ theme }) => `
max-width: ${theme.sizeUnit * 25}px;
`}
${ellipsisCss}
`;
const StyledColumn = styled('span')`
${({ theme }) => `
max-width: ${theme.sizeUnit * 25}px;
padding-right: ${theme.sizeUnit}px;
`}
${ellipsisCss}
`;
const StyledTag = styled(Tag)`
${({ theme }) => `
border: 1px solid ${theme.colorBorder};
border-radius: 2px;
.anticon-close {
vertical-align: middle;
}
`}
`;
const UrlFilterTag = (props: {
filter: UrlFilterIndicator;
orientation: FilterBarOrientation;
onRemove: (filter: UrlFilterIndicator) => void;
}) => {
const { filter, orientation, onRemove } = props;
const theme = useTheme();
const [columnRef, columnIsTruncated] =
useCSSTextTruncation<HTMLSpanElement>();
const [valueRef, valueIsTruncated] = useCSSTextTruncation<HTMLSpanElement>();
return (
<StyledTag
css={css`
${orientation === FilterBarOrientation.Vertical
? `margin-top: ${theme.sizeUnit * 2}px;`
: `margin-left: ${theme.sizeUnit * 2}px;`}
`}
closable
onClose={() => onRemove(filter)}
>
<Tooltip title={columnIsTruncated ? filter.subject : null}>
<StyledColumn ref={columnRef}>{filter.subject}</StyledColumn>
</Tooltip>
<Tooltip title={valueIsTruncated ? filter.value : null}>
<StyledValue ref={valueRef}>{filter.value}</StyledValue>
</Tooltip>
</StyledTag>
);
};
export default UrlFilterTag;

View File

@@ -0,0 +1,139 @@
/**
* 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.
*/
/**
* Validates PR review items #2 and #7:
* - chip removal must dispatch a dataMask update so charts re-query
* (otherwise the URL changes but the synthetic Rison filter keeps
* influencing chart results until full reload).
* - the chip list must react to URL changes (back/forward navigation or
* a programmatic history.replace), not snapshot the URL at mount.
*/
import { createMemoryHistory } from 'history';
import { Router } from 'react-router-dom';
import { act, render, screen, userEvent } from 'spec/helpers/testing-library';
import { REMOVE_DATA_MASK, UPDATE_DATA_MASK } from 'src/dataMask/actions';
import { RISON_UNMATCHED_DATAMASK_ID } from 'src/dashboard/util/risonFilters';
import UrlFiltersVertical from './Vertical';
const mockDispatch = jest.fn();
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: () => mockDispatch,
}));
const seedUrl = (search: string) => {
// jsdom doesn't navigate, so set both window.location (read by
// getRisonFilterParam) and react-router's in-memory history.
window.history.replaceState({}, '', `/superset/dashboard/1/${search}`);
};
const renderAt = (search: string) => {
seedUrl(search);
const history = createMemoryHistory({
initialEntries: [`/superset/dashboard/1/${search}`],
});
const utils = render(
<Router history={history}>
<UrlFiltersVertical />
</Router>,
{ useRedux: true },
);
return { ...utils, history };
};
beforeEach(() => {
mockDispatch.mockReset();
window.history.replaceState({}, '', '/');
});
test('renders chips parsed from the f= URL param', () => {
renderAt('?f=(region:EMEA,channel:web)');
expect(screen.getByText('URL Filters')).toBeInTheDocument();
expect(screen.getByText('region')).toBeInTheDocument();
expect(screen.getByText('EMEA')).toBeInTheDocument();
expect(screen.getByText('channel')).toBeInTheDocument();
expect(screen.getByText('web')).toBeInTheDocument();
});
test('renders nothing when there is no f= param', () => {
renderAt('');
expect(screen.queryByText('URL Filters')).not.toBeInTheDocument();
});
test('removing a chip dispatches updateDataMask with the remaining filters', async () => {
renderAt('?f=(region:EMEA,channel:web)');
const closeButtons = screen.getAllByRole('img', { name: /close/i });
expect(closeButtons).toHaveLength(2);
await userEvent.click(closeButtons[0]);
// The remaining filter must still apply to charts, so updateDataMask
// (not removeDataMask) is dispatched with one filter left.
const updateCalls = mockDispatch.mock.calls
.map(([action]) => action)
.filter(action => action?.type === UPDATE_DATA_MASK);
expect(updateCalls).toHaveLength(1);
expect(updateCalls[0].filterId).toBe(RISON_UNMATCHED_DATAMASK_ID);
expect(updateCalls[0].dataMask.extraFormData.filters).toHaveLength(1);
expect(updateCalls[0].dataMask.extraFormData.filters[0]).toMatchObject({
col: 'channel',
val: ['web'],
});
// The chip we clicked is gone from the UI.
expect(screen.queryByText('region')).not.toBeInTheDocument();
});
test('removing the last chip dispatches removeDataMask, not an empty update', async () => {
renderAt('?f=(region:EMEA)');
await userEvent.click(screen.getByRole('img', { name: /close/i }));
const removeCalls = mockDispatch.mock.calls
.map(([action]) => action)
.filter(action => action?.type === REMOVE_DATA_MASK);
expect(removeCalls).toHaveLength(1);
expect(removeCalls[0].filterId).toBe(RISON_UNMATCHED_DATAMASK_ID);
// No stray updateDataMask with empty filters.
const updateCalls = mockDispatch.mock.calls
.map(([action]) => action)
.filter(action => action?.type === UPDATE_DATA_MASK);
expect(updateCalls).toHaveLength(0);
});
test('chip list re-renders when the URL changes (popstate/programmatic nav)', () => {
const { history } = renderAt('?f=(region:EMEA)');
expect(screen.getByText('region')).toBeInTheDocument();
expect(screen.queryByText('priority')).not.toBeInTheDocument();
// Simulate a programmatic URL change (e.g. via history.push or a back/
// forward nav). The component must re-read the URL filters.
act(() => {
seedUrl('?f=(priority:high)');
history.replace('/superset/dashboard/1/?f=(priority:high)');
});
expect(screen.getByText('priority')).toBeInTheDocument();
expect(screen.getByText('high')).toBeInTheDocument();
expect(screen.queryByText('region')).not.toBeInTheDocument();
});

View File

@@ -0,0 +1,96 @@
/**
* 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 { useCallback, useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { QueryObjectFilterClause } from '@superset-ui/core';
import { removeDataMask, updateDataMask } from 'src/dataMask/actions';
import {
getRisonFilterParam,
parseRisonFilters,
RISON_UNMATCHED_DATAMASK_ID,
risonFiltersToExtraFormDataFilters,
updateUrlWithUnmatchedFilters,
} from 'src/dashboard/util/risonFilters';
import {
getUrlFilterIdentity,
getUrlFilterIndicators,
UrlFilterIndicator,
} from './urlFilterUtils';
import UrlFiltersVerticalCollapse from './VerticalCollapse';
const UrlFiltersVertical = () => {
const dispatch = useDispatch();
const history = useHistory();
const location = useLocation();
const [urlFilters, setUrlFilters] = useState<UrlFilterIndicator[]>(() =>
getUrlFilterIndicators(),
);
// Re-read chips whenever the URL changes (back/forward navigation, or a
// programmatic history.replace).
useEffect(() => {
setUrlFilters(getUrlFilterIndicators());
}, [location.search]);
const handleRemoveFilter = useCallback(
(filterToRemove: UrlFilterIndicator) => {
const risonParam = getRisonFilterParam();
if (!risonParam) return;
const removeId = getUrlFilterIdentity(filterToRemove.filter);
const currentFilters = parseRisonFilters(risonParam);
const remaining = currentFilters.filter(
f => getUrlFilterIdentity(f) !== removeId,
);
updateUrlWithUnmatchedFilters(remaining, history);
setUrlFilters(prev =>
prev.filter(f => getUrlFilterIdentity(f.filter) !== removeId),
);
if (remaining.length === 0) {
dispatch(removeDataMask(RISON_UNMATCHED_DATAMASK_ID));
} else {
const extraFormDataFilters: QueryObjectFilterClause[] =
risonFiltersToExtraFormDataFilters(remaining);
dispatch(
updateDataMask(RISON_UNMATCHED_DATAMASK_ID, {
extraFormData: { filters: extraFormDataFilters },
}),
);
}
},
[dispatch, history],
);
if (!urlFilters.length) {
return null;
}
return (
<UrlFiltersVerticalCollapse
urlFilters={urlFilters}
onRemoveFilter={handleRemoveFilter}
/>
);
};
export default UrlFiltersVertical;

View File

@@ -0,0 +1,131 @@
/**
* 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 { useCallback, useMemo, useState } from 'react';
import { t } from '@apache-superset/core/translation';
import { css, useTheme, SupersetTheme } from '@apache-superset/core/theme';
import { Icons } from '@superset-ui/core/components/Icons';
import { FilterBarOrientation } from 'src/dashboard/types';
import UrlFilterTag from './UrlFilterTag';
import { UrlFilterIndicator, getUrlFilterIdentity } from './urlFilterUtils';
const sectionContainerStyle = (theme: SupersetTheme) => css`
margin-bottom: ${theme.sizeUnit * 3}px;
padding: 0 ${theme.sizeUnit * 4}px;
`;
const sectionHeaderStyle = (theme: SupersetTheme) => css`
display: flex;
align-items: center;
justify-content: space-between;
padding: ${theme.sizeUnit * 2}px 0;
cursor: pointer;
user-select: none;
&:hover {
background: ${theme.colorBgTextHover};
margin: 0 -${theme.sizeUnit * 2}px;
padding: ${theme.sizeUnit * 2}px;
border-radius: ${theme.borderRadius}px;
}
`;
const sectionTitleStyle = (theme: SupersetTheme) => css`
margin: 0;
font-size: ${theme.fontSize}px;
font-weight: ${theme.fontWeightStrong};
color: ${theme.colorText};
line-height: 1.3;
display: flex;
align-items: center;
gap: ${theme.sizeUnit}px;
`;
const sectionContentStyle = (theme: SupersetTheme) => css`
padding: ${theme.sizeUnit * 2}px 0;
`;
const dividerStyle = (theme: SupersetTheme) => css`
height: 1px;
background: ${theme.colorSplit};
margin: ${theme.sizeUnit * 2}px 0;
`;
const iconStyle = (open: boolean, theme: SupersetTheme) => css`
transform: ${open ? 'rotate(0deg)' : 'rotate(180deg)'};
transition: transform 0.2s ease;
color: ${theme.colorTextSecondary};
`;
const UrlFiltersVerticalCollapse = (props: {
urlFilters: UrlFilterIndicator[];
onRemoveFilter: (filter: UrlFilterIndicator) => void;
}) => {
const { urlFilters, onRemoveFilter } = props;
const theme = useTheme();
const [isOpen, setIsOpen] = useState(true);
const toggleSection = useCallback(() => {
setIsOpen(prev => !prev);
}, []);
const filterIndicators = useMemo(
() =>
urlFilters.map(filter => (
<UrlFilterTag
key={getUrlFilterIdentity(filter.filter)}
filter={filter}
orientation={FilterBarOrientation.Vertical}
onRemove={onRemoveFilter}
/>
)),
[urlFilters, onRemoveFilter],
);
if (!urlFilters.length) {
return null;
}
return (
<div css={sectionContainerStyle}>
<div
css={sectionHeaderStyle}
onClick={toggleSection}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleSection();
}
}}
role="button"
tabIndex={0}
>
<h4 css={sectionTitleStyle}>
<Icons.LinkOutlined iconSize="s" />
{t('URL Filters')}
</h4>
<Icons.UpOutlined iconSize="m" css={iconStyle(isOpen, theme)} />
</div>
{isOpen && <div css={sectionContentStyle}>{filterIndicators}</div>}
{isOpen && <div css={dividerStyle} data-test="url-filters-divider" />}
</div>
);
};
export default UrlFiltersVerticalCollapse;

View File

@@ -0,0 +1,71 @@
/**
* 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 {
getRisonFilterParam,
parseRisonFilters,
RisonFilter,
} from 'src/dashboard/util/risonFilters';
export interface UrlFilterIndicator {
subject: string;
operator: string;
value: string;
filter: RisonFilter;
}
/**
* Build a stable identity string for a URL filter so duplicates on the same
* column (e.g., multiple conditions on `country`) get distinct React keys and
* can be removed individually.
*/
export function getUrlFilterIdentity(filter: RisonFilter): string {
return `${filter.subject}|${filter.operator}|${JSON.stringify(
filter.comparator,
)}`;
}
function formatFilterValue(filter: RisonFilter): string {
const { comparator, operator } = filter;
if (operator === 'BETWEEN' && Array.isArray(comparator)) {
return `${comparator[0]} ${comparator[1]}`;
}
if (Array.isArray(comparator)) {
return comparator.join(', ');
}
return String(comparator);
}
export function getUrlFilterIndicators(): UrlFilterIndicator[] {
const risonParam = getRisonFilterParam();
if (!risonParam) {
return [];
}
const filters = parseRisonFilters(risonParam);
return filters.map(filter => ({
subject: filter.subject,
operator: filter.operator,
value: formatFilterValue(filter),
filter,
}));
}

View File

@@ -45,6 +45,7 @@ import Header from './Header';
import FilterControls from './FilterControls/FilterControls';
import CrossFiltersVertical from './CrossFilters/Vertical';
import crossFiltersSelector from './CrossFilters/selectors';
import UrlFiltersVertical from './UrlFilters/Vertical';
enum SectionType {
Filters = 'filters',
@@ -301,6 +302,7 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
) : (
<div css={tabPaneStyle} onScroll={onScroll}>
<>
<UrlFiltersVertical />
<CrossFiltersVertical hideHeader={hasOnlyOneSectionType} />
{filterControls}
</>

View File

@@ -107,8 +107,14 @@ const publishDataMask = debounce(
const previousParams = new URLSearchParams(search);
const newParams = new URLSearchParams();
let dataMaskKey: string | null;
// Capture the raw, still-URL-encoded `f=` payload from the query string
// directly. URLSearchParams decodes values (and turns `+` into space), which
// would corrupt the Rison payload if we re-inserted it without re-encoding.
const rawRisonMatch = search.match(/[?&]f=([^&]*)/);
const rawRisonFilterValue = rawRisonMatch ? rawRisonMatch[1] : null;
previousParams.forEach((value, key) => {
if (!EXCLUDED_URL_PARAMS.includes(key)) {
if (!EXCLUDED_URL_PARAMS.includes(key) && key !== 'f') {
newParams.append(key, value);
}
});
@@ -148,9 +154,16 @@ const publishDataMask = debounce(
if (appRoot !== '/' && replacementPathname.startsWith(appRoot)) {
replacementPathname = replacementPathname.substring(appRoot.length);
}
// Manually reconstruct the search string to preserve Rison filter encoding
let searchString = newParams.toString();
if (rawRisonFilterValue) {
const separator = searchString ? '&' : '';
searchString = `${searchString}${separator}f=${rawRisonFilterValue}`;
}
history.replace({
pathname: replacementPathname,
search: newParams.toString(),
search: searchString,
});
}
},

View File

@@ -64,6 +64,18 @@ import SyncDashboardState, {
getDashboardContextLocalStorage,
} from '../components/SyncDashboardState';
import { AutoRefreshProvider } from '../contexts/AutoRefreshContext';
import { Filter, PartialFilters } from '@superset-ui/core';
import {
parseRisonFilters,
risonFiltersToExtraFormDataFilters,
getRisonFilterParam,
prettifyRisonFilterUrl,
injectRisonFiltersIntelligently,
updateUrlWithUnmatchedFilters,
RISON_UNMATCHED_DATAMASK_ID,
} from '../util/risonFilters';
type NativeFilterConfigEntry = Partial<Filter> & { id: string };
export const DashboardPageIdContext = createContext('');
@@ -195,6 +207,63 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
dataMask = isOldRison;
}
// Parse Rison URL filters with intelligent native filter injection
const risonFilterParam = getRisonFilterParam();
if (risonFilterParam) {
const risonFilters = parseRisonFilters(risonFilterParam);
if (risonFilters.length > 0) {
// Convert native filter config array to keyed object for lookup
const filterConfigArray = (dashboard?.metadata
?.native_filter_configuration ?? []) as NativeFilterConfigEntry[];
const nativeFilters: PartialFilters = {};
filterConfigArray.forEach(filter => {
nativeFilters[filter.id] = filter;
});
const injectionResult = injectRisonFiltersIntelligently(
risonFilters,
nativeFilters,
dataMask,
);
dataMask = injectionResult.updatedDataMask;
// Unmatched filters apply via a synthetic dataMask entry: because no
// entry in `nativeFilters` claims this id, `getAllActiveFilters`
// falls through to `allSliceIds` and the filters scope to every chart.
if (injectionResult.unmatchedFilters.length > 0) {
const extraFormDataFilters = risonFiltersToExtraFormDataFilters(
injectionResult.unmatchedFilters,
);
dataMask = {
...dataMask,
[RISON_UNMATCHED_DATAMASK_ID]: {
id: RISON_UNMATCHED_DATAMASK_ID,
extraFormData: { filters: extraFormDataFilters },
filterState: {},
ownState: {},
},
};
}
// Rewrite the URL to drop matched filters in a single step, keeping
// only unmatched ones (and prettifying their encoding). Going
// through react-router's history keeps `history.location.search` in
// sync so `publishDataMask` doesn't re-emit the original `f=`.
const matchedCount =
risonFilters.length - injectionResult.unmatchedFilters.length;
if (matchedCount > 0) {
updateUrlWithUnmatchedFilters(
injectionResult.unmatchedFilters,
history,
);
}
if (injectionResult.unmatchedFilters.length > 0) {
prettifyRisonFilterUrl();
}
}
}
if (readyToRender) {
if (!isDashboardHydrated.current) {
isDashboardHydrated.current = true;

View File

@@ -0,0 +1,110 @@
/**
* 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.
*/
/**
* Pinning test for the unmatched-Rison-filter wiring (PR review item #1).
*
* The reviewer flagged that unmatched URL filters were being written to
* dataMask under a key (`__rison_filters__`) that no downstream code reads —
* so unmatched filters silently dropped instead of applying to charts.
*
* The fix uses a synthetic dataMask id (`RISON_UNMATCHED_DATAMASK_ID`) whose
* `extraFormData.filters` falls through to `getAllActiveFilters`'s
* `allSliceIds` scope fallback, hitting every chart on the dashboard. This
* test pins down that contract so a future refactor of `getAllActiveFilters`
* can't silently break the wiring again.
*/
import { DataMaskStateWithId } from '@superset-ui/core';
import { getAllActiveFilters } from './activeAllDashboardFilters';
import {
RISON_UNMATCHED_DATAMASK_ID,
risonFiltersToExtraFormDataFilters,
} from './risonFilters';
test('synthetic Rison-unmatched dataMask entry scopes to every chart', () => {
const allSliceIds = [101, 202, 303];
const dataMask: DataMaskStateWithId = {
[RISON_UNMATCHED_DATAMASK_ID]: {
id: RISON_UNMATCHED_DATAMASK_ID,
extraFormData: {
filters: risonFiltersToExtraFormDataFilters([
{ subject: 'region', operator: '==', comparator: 'EMEA' },
]),
},
filterState: {},
ownState: {},
},
};
const activeFilters = getAllActiveFilters({
chartConfiguration: {},
nativeFilters: {}, // no native filter claims the synthetic id
dataMask,
allSliceIds,
});
const entry = activeFilters[RISON_UNMATCHED_DATAMASK_ID];
expect(entry).toBeDefined();
// Scope MUST equal allSliceIds — that's the whole reason this works.
expect(entry.scope).toEqual(allSliceIds);
// And the filters must be in the {col, op, val} shape getExtraFormData merges.
expect(entry.values).toEqual({
filters: [{ col: 'region', op: 'IN', val: ['EMEA'] }],
});
});
test('synthetic entry coexists with real native filters without overlap', () => {
const allSliceIds = [1, 2, 3];
const dataMask: DataMaskStateWithId = {
NATIVE_FILTER_country: {
id: 'NATIVE_FILTER_country',
extraFormData: {
filters: [{ col: 'country', op: 'IN', val: ['USA'] }],
},
filterState: { value: ['USA'] },
ownState: {},
},
[RISON_UNMATCHED_DATAMASK_ID]: {
id: RISON_UNMATCHED_DATAMASK_ID,
extraFormData: {
filters: [{ col: 'region', op: 'IN', val: ['EMEA'] }],
},
filterState: {},
ownState: {},
},
};
const activeFilters = getAllActiveFilters({
chartConfiguration: {},
nativeFilters: {
NATIVE_FILTER_country: {
id: 'NATIVE_FILTER_country',
chartsInScope: [1], // native filter is narrow
targets: [{ column: { name: 'country' } }],
filterType: 'filter_select',
},
},
dataMask,
allSliceIds,
});
// Native filter keeps its narrow scope.
expect(activeFilters.NATIVE_FILTER_country.scope).toEqual([1]);
// Synthetic filter scopes to all slices.
expect(activeFilters[RISON_UNMATCHED_DATAMASK_ID].scope).toEqual(allSliceIds);
});

View File

@@ -0,0 +1,411 @@
/**
* 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 { PartialFilters, DataMaskStateWithId } from '@superset-ui/core';
import {
injectRisonFiltersIntelligently,
RisonFilter,
parseRisonFilters,
risonFiltersToExtraFormDataFilters,
risonFiltersToString,
risonToAdhocFilters,
updateUrlWithUnmatchedFilters,
} from './risonFilters';
const mockNativeFilters: PartialFilters = {
filter_1: {
id: 'filter_1',
targets: [
{
column: { name: 'country' },
datasetId: 1,
},
],
filterType: 'filter_select',
},
filter_2: {
id: 'filter_2',
targets: [
{
column: { name: 'year' },
datasetId: 1,
},
],
filterType: 'filter_range',
},
filter_3: {
id: 'filter_3',
targets: [
{
column: { name: 'Country Code' },
datasetId: 1,
},
],
filterType: 'filter_select',
},
};
const mockDataMask: DataMaskStateWithId = {
filter_1: {
id: 'filter_1',
filterState: { value: undefined },
ownState: {},
},
};
test('should parse simple Rison filters', () => {
const risonString = '(country:USA,year:2024)';
const result = parseRisonFilters(risonString);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
subject: 'country',
operator: '==',
comparator: 'USA',
});
expect(result[1]).toEqual({
subject: 'year',
operator: '==',
comparator: 2024,
});
});
test('should parse IN operator with array syntax', () => {
const result = parseRisonFilters('(country:!(USA,Canada))');
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
subject: 'country',
operator: 'IN',
comparator: ['USA', 'Canada'],
});
});
test('should parse BETWEEN operator', () => {
const result = parseRisonFilters('(msrp:(between:!(35,200)))');
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
subject: 'msrp',
operator: 'BETWEEN',
comparator: [35, 200],
});
});
test('should parse NOT operator', () => {
const result = parseRisonFilters('(NOT:(country:USA))');
expect(result).toHaveLength(1);
expect(result[0].operator).toBe('!=');
expect(result[0].comparator).toBe('USA');
});
test('should parse comparison operators', () => {
expect(parseRisonFilters('(sales:(gt:100000))')[0].operator).toBe('>');
expect(parseRisonFilters('(age:(gte:18))')[0].operator).toBe('>=');
expect(parseRisonFilters('(temp:(lt:32))')[0].operator).toBe('<');
expect(parseRisonFilters('(price:(lte:1000))')[0].operator).toBe('<=');
});
test('should return empty array for invalid Rison', () => {
expect(parseRisonFilters('invalid rison')).toEqual([]);
expect(parseRisonFilters('(unclosed')).toEqual([]);
});
test('should match Rison filter to native filter by column name', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'country', operator: '==', comparator: 'USA' },
];
const result = injectRisonFiltersIntelligently(
risonFilters,
mockNativeFilters,
mockDataMask,
);
expect(result.updatedDataMask.filter_1.filterState?.value).toEqual(['USA']);
expect(result.unmatchedFilters).toHaveLength(0);
});
test('should match column names with spaces (case-insensitive)', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'Country Code', operator: '==', comparator: 'USA' },
];
const result = injectRisonFiltersIntelligently(
risonFilters,
mockNativeFilters,
mockDataMask,
);
expect(result.updatedDataMask.filter_3.filterState?.value).toEqual(['USA']);
expect(result.unmatchedFilters).toHaveLength(0);
});
test('should match column names case-insensitively', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'country code', operator: '==', comparator: 'USA' },
];
const result = injectRisonFiltersIntelligently(
risonFilters,
mockNativeFilters,
mockDataMask,
);
expect(result.updatedDataMask.filter_3.filterState?.value).toEqual(['USA']);
expect(result.unmatchedFilters).toHaveLength(0);
});
test('should handle unmatched filters with fallback', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'region', operator: '==', comparator: 'North America' },
];
const result = injectRisonFiltersIntelligently(
risonFilters,
mockNativeFilters,
mockDataMask,
);
expect(result.unmatchedFilters).toHaveLength(1);
expect(result.unmatchedFilters[0].subject).toBe('region');
});
test('should convert values correctly for different filter types', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'country', operator: '==', comparator: 'USA' },
{ subject: 'year', operator: 'BETWEEN', comparator: [2020, 2024] },
];
const result = injectRisonFiltersIntelligently(
risonFilters,
mockNativeFilters,
mockDataMask,
);
// Select filter should be array
expect(result.updatedDataMask.filter_1.filterState?.value).toEqual(['USA']);
// Range filter should be min/max object
expect(result.updatedDataMask.filter_2.filterState?.value).toEqual({
min: 2020,
max: 2024,
});
expect(result.unmatchedFilters).toHaveLength(0);
});
test('should set extraFormData for auto-application on select filters', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'country', operator: '==', comparator: 'USA' },
];
const result = injectRisonFiltersIntelligently(
risonFilters,
mockNativeFilters,
mockDataMask,
);
expect(result.updatedDataMask.filter_1.extraFormData).toEqual({
filters: [{ col: 'country', op: 'IN', val: ['USA'] }],
});
});
test('should set extraFormData for auto-application on IN filters', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'country', operator: 'IN', comparator: ['USA', 'Canada'] },
];
const result = injectRisonFiltersIntelligently(
risonFilters,
mockNativeFilters,
mockDataMask,
);
expect(result.updatedDataMask.filter_1.filterState?.value).toEqual([
'USA',
'Canada',
]);
expect(result.updatedDataMask.filter_1.extraFormData).toEqual({
filters: [{ col: 'country', op: 'IN', val: ['USA', 'Canada'] }],
});
});
test('should set extraFormData for auto-application on BETWEEN filters', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'year', operator: 'BETWEEN', comparator: [2020, 2024] },
];
const result = injectRisonFiltersIntelligently(
risonFilters,
mockNativeFilters,
mockDataMask,
);
expect(result.updatedDataMask.filter_2.filterState?.value).toEqual({
min: 2020,
max: 2024,
});
expect(result.updatedDataMask.filter_2.extraFormData).toEqual({
filters: [
{ col: 'year', op: '>=', val: 2020 },
{ col: 'year', op: '<=', val: 2024 },
],
});
});
test('should handle mixed matched and unmatched filters', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'country', operator: '==', comparator: 'USA' },
{ subject: 'category', operator: '==', comparator: 'Sales' },
];
const result = injectRisonFiltersIntelligently(
risonFilters,
mockNativeFilters,
mockDataMask,
);
expect(result.updatedDataMask.filter_1.filterState?.value).toEqual(['USA']);
expect(result.unmatchedFilters).toHaveLength(1);
expect(result.unmatchedFilters[0].subject).toBe('category');
});
test('should convert filters to adhoc format', () => {
const risonFilters: RisonFilter[] = [
{ subject: 'country', operator: '==', comparator: 'USA' },
];
const adhocFilters = risonToAdhocFilters(risonFilters);
expect(adhocFilters).toHaveLength(1);
expect(adhocFilters[0]).toMatchObject({
expressionType: 'SIMPLE',
clause: 'WHERE',
subject: 'country',
operator: '==',
comparator: 'USA',
});
});
test('should convert filters to Rison string', () => {
const filters: RisonFilter[] = [
{ subject: 'country', operator: '==', comparator: 'USA' },
];
const result = risonFiltersToString(filters);
expect(result).toBe('(country:USA)');
});
test('should convert IN filters to Rison string', () => {
const filters: RisonFilter[] = [
{ subject: 'country', operator: 'IN', comparator: ['USA', 'Canada'] },
];
const result = risonFiltersToString(filters);
expect(result).toBe('(country:!(USA,Canada))');
});
test('should return empty string for empty filters', () => {
expect(risonFiltersToString([])).toBe('');
});
test('risonFiltersToExtraFormDataFilters expands BETWEEN into two clauses', () => {
const filters: RisonFilter[] = [
{ subject: 'country', operator: '==', comparator: 'USA' },
{ subject: 'year', operator: 'BETWEEN', comparator: [2020, 2024] },
];
expect(risonFiltersToExtraFormDataFilters(filters)).toEqual([
{ col: 'country', op: 'IN', val: ['USA'] },
{ col: 'year', op: '>=', val: 2020 },
{ col: 'year', op: '<=', val: 2024 },
]);
});
test('updateUrlWithUnmatchedFilters goes through history when supplied', () => {
const replace = jest.fn();
const history = { replace };
// Seed the URL so the function has something to read.
const originalLocation = window.location.href;
window.history.replaceState({}, '', '/superset/dashboard/1/?f=(country:USA)');
updateUrlWithUnmatchedFilters(
[{ subject: 'region', operator: '==', comparator: 'EMEA' }],
history,
);
expect(replace).toHaveBeenCalledTimes(1);
const call = replace.mock.calls[0][0];
expect(call.pathname).toBe('/superset/dashboard/1/');
expect(call.search).toContain('f=');
expect(call.search).toContain('region');
// Restore.
window.history.replaceState({}, '', originalLocation);
});
test('updateUrlWithUnmatchedFilters drops f= when no unmatched remain', () => {
const replace = jest.fn();
const originalLocation = window.location.href;
window.history.replaceState({}, '', '/superset/dashboard/1/?f=(country:USA)');
updateUrlWithUnmatchedFilters([], { replace });
expect(replace).toHaveBeenCalledTimes(1);
expect(replace.mock.calls[0][0].search).toBe('');
window.history.replaceState({}, '', originalLocation);
});
test('updateUrlWithUnmatchedFilters cleanup is observable by history readers', () => {
// Validates PR review item #3: the URL cleanup must go through a path
// that downstream history-readers (e.g. publishDataMask) observe. The
// raw window.history.replaceState fallback alone left react-router's
// history.location.search stale, causing publishDataMask to re-append
// the original f= on the next interaction.
//
// Stand in for react-router's history with a fake whose `.location`
// updates synchronously when .replace is called — same contract as
// react-router-dom's history.replace.
const fakeHistory = {
location: {
pathname: '/superset/dashboard/1/',
search: '?f=(country:USA)',
},
replace(next: { pathname: string; search: string }) {
this.location = next;
},
};
const originalLocation = window.location.href;
window.history.replaceState({}, '', '/superset/dashboard/1/?f=(country:USA)');
updateUrlWithUnmatchedFilters(
[{ subject: 'sales', operator: '>', comparator: 1000 }],
fakeHistory,
);
// After cleanup, a reader of history.location.search (the same path
// publishDataMask uses) must NOT see the original matched filter.
expect(fakeHistory.location.search).not.toContain('country');
expect(fakeHistory.location.search).toContain('sales');
window.history.replaceState({}, '', originalLocation);
});

View File

@@ -0,0 +1,569 @@
/**
* 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 {
QueryObjectFilterClause,
PartialFilters,
DataMaskStateWithId,
} from '@superset-ui/core';
import rison from 'rison';
/**
* Synthetic dataMask key for URL Rison filters that don't match any native
* filter on the dashboard. Because no entry in `nativeFilters` claims this id,
* `getAllActiveFilters` falls through to `allSliceIds`, so the attached
* `extraFormData.filters` apply to every chart on the dashboard.
*/
export const RISON_UNMATCHED_DATAMASK_ID = '__rison_unmatched__';
export interface RisonFilter {
subject: string;
operator: string;
comparator: string | number | boolean | (string | number)[];
}
export interface IntelligentRisonInjectionResult {
updatedDataMask: DataMaskStateWithId;
unmatchedFilters: RisonFilter[];
}
/**
* Parse individual filter condition
*/
function parseFilterCondition(key: string, value: unknown): RisonFilter {
// Handle comparison operators: (gt:100), (between:!(1,10))
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
const [operator, operatorValue] = Object.entries(
value as Record<string, unknown>,
)[0];
switch (operator) {
case 'gt':
return {
subject: key,
operator: '>',
comparator: operatorValue as string | number,
};
case 'gte':
return {
subject: key,
operator: '>=',
comparator: operatorValue as string | number,
};
case 'lt':
return {
subject: key,
operator: '<',
comparator: operatorValue as string | number,
};
case 'lte':
return {
subject: key,
operator: '<=',
comparator: operatorValue as string | number,
};
case 'between':
return {
subject: key,
operator: 'BETWEEN',
comparator: operatorValue as (string | number)[],
};
case 'like':
return {
subject: key,
operator: 'LIKE',
comparator: operatorValue as string,
};
default:
return {
subject: key,
operator: '==',
comparator: value as unknown as string | number,
};
}
}
// Handle IN operator: !(value1,value2)
if (Array.isArray(value)) {
return {
subject: key,
operator: 'IN',
comparator: value as (string | number)[],
};
}
// Handle simple equality
return {
subject: key,
operator: '==',
comparator: value as string | number | boolean,
};
}
/**
* Parse Rison filter syntax from URL parameter.
* Supports formats like: (country:USA,year:2024)
*/
export function parseRisonFilters(risonString: string): RisonFilter[] {
try {
const parsed = rison.decode(risonString);
const filters: RisonFilter[] = [];
if (!parsed || typeof parsed !== 'object') {
return filters;
}
const parsedObj = parsed as Record<string, unknown>;
// Handle OR operator: OR:!(condition1,condition2)
if (parsedObj.OR && Array.isArray(parsedObj.OR)) {
(parsedObj.OR as Record<string, unknown>[]).forEach(condition => {
if (typeof condition === 'object') {
Object.entries(condition).forEach(([key, value]) => {
filters.push(parseFilterCondition(key, value));
});
}
});
return filters;
}
// Handle NOT operator: NOT:(condition). Falls through so regular keys at the
// same level are still picked up below (supports mixed payloads like
// `(country:USA,NOT:(status:archived))`).
if (parsedObj.NOT && typeof parsedObj.NOT === 'object') {
Object.entries(parsedObj.NOT as Record<string, unknown>).forEach(
([key, value]) => {
const filter = parseFilterCondition(key, value);
if (filter.operator === '==') {
filter.operator = '!=';
} else if (filter.operator === 'IN') {
filter.operator = 'NOT IN';
}
filters.push(filter);
},
);
}
// Handle regular filters
Object.entries(parsedObj).forEach(([key, value]) => {
if (key !== 'OR' && key !== 'NOT') {
filters.push(parseFilterCondition(key, value));
}
});
return filters;
} catch (error) {
console.warn('Failed to parse Rison filters:', error);
return [];
}
}
/**
* Convert Rison filters to Superset adhoc filter format
*/
export function risonToAdhocFilters(
risonFilters: RisonFilter[],
): QueryObjectFilterClause[] {
return risonFilters.map(
filter =>
({
expressionType: 'SIMPLE' as const,
clause: 'WHERE' as const,
subject: filter.subject,
operator: filter.operator,
comparator: filter.comparator,
}) as unknown as QueryObjectFilterClause,
);
}
/**
* Prettify Rison filter URL by replacing encoded characters.
* Uses browser history API to update URL without page reload.
*/
export function prettifyRisonFilterUrl(): void {
try {
const currentUrl = window.location.href;
if (!currentUrl.includes('&f=') && !currentUrl.includes('?f=')) {
return;
}
const urlMatch = currentUrl.match(/([?&])f=([^&]*)/);
if (!urlMatch) {
return;
}
const separator = urlMatch[1];
let risonValue = urlMatch[2];
if (!risonValue.includes('%') && !risonValue.includes('+')) {
return;
}
let previousValue = '';
let decodeAttempts = 0;
while (risonValue !== previousValue && decodeAttempts < 5) {
previousValue = risonValue;
try {
if (risonValue.includes('%')) {
risonValue = decodeURIComponent(risonValue);
}
} catch {
break;
}
decodeAttempts += 1;
}
risonValue = risonValue.replace(/\+/g, ' ');
const matchIndex = urlMatch.index ?? 0;
const beforeRison = currentUrl.substring(0, matchIndex);
const afterRison = currentUrl.substring(matchIndex + urlMatch[0].length);
const prettifiedUrl = `${beforeRison}${separator}f=${risonValue}${afterRison}`;
if (prettifiedUrl !== currentUrl) {
window.history.replaceState(window.history.state, '', prettifiedUrl);
}
} catch (error) {
console.warn('Failed to prettify Rison URL:', error);
}
}
/**
* Get Rison filter parameter from URL
*/
export function getRisonFilterParam(): string | null {
const params = new URLSearchParams(window.location.search);
return params.get('f');
}
/**
* Convert an array of RisonFilter back to Rison string format
*/
export function risonFiltersToString(filters: RisonFilter[]): string {
if (filters.length === 0) {
return '';
}
type RisonValue =
| string
| number
| boolean
| (string | number)[]
| Record<string, unknown>;
const risonObject: Record<string, RisonValue> = {};
const notObject: Record<string, RisonValue> = {};
const encodePositive = (
target: Record<string, RisonValue>,
filter: RisonFilter,
op: string,
) => {
if (op === 'IN' && Array.isArray(filter.comparator)) {
target[filter.subject] = filter.comparator;
} else if (op === '==') {
target[filter.subject] = filter.comparator;
} else {
const operatorMap: Record<string, string> = {
'>': 'gt',
'>=': 'gte',
'<': 'lt',
'<=': 'lte',
BETWEEN: 'between',
LIKE: 'like',
ILIKE: 'ilike',
};
const risonOp = operatorMap[op] || op;
target[filter.subject] = { [risonOp]: filter.comparator };
}
};
filters.forEach(filter => {
if (filter.operator === '!=') {
// Re-emit as NOT:(col:value) so parseRisonFilters can read it back.
encodePositive(notObject, filter, '==');
} else if (filter.operator === 'NOT IN') {
encodePositive(notObject, filter, 'IN');
} else {
encodePositive(risonObject, filter, filter.operator);
}
});
if (Object.keys(notObject).length > 0) {
risonObject.NOT = notObject;
}
try {
return rison.encode(risonObject);
} catch (error) {
console.warn('Failed to encode Rison filters:', error);
return '';
}
}
interface ReplaceHistory {
replace(location: { pathname: string; search: string }): void;
}
/**
* Update the URL to remove successfully matched filters, keeping only unmatched ones.
* When a react-router history is supplied, the update goes through it so that
* components reading from `history.location` (e.g. `publishDataMask` in the
* filter bar) see the new search string. Otherwise falls back to a raw
* `window.history.replaceState`.
*/
export function updateUrlWithUnmatchedFilters(
unmatchedFilters: RisonFilter[],
history?: ReplaceHistory,
): void {
try {
const currentUrl = new URL(window.location.href);
if (unmatchedFilters.length === 0) {
currentUrl.searchParams.delete('f');
} else {
const newRisonString = risonFiltersToString(unmatchedFilters);
if (newRisonString) {
currentUrl.searchParams.set('f', newRisonString);
} else {
currentUrl.searchParams.delete('f');
}
}
// Always keep window.history in sync so callers that read
// `window.location.search` (e.g. `getRisonFilterParam`) see the update.
// With a real `BrowserRouter`, `history.replace` would do this too — but
// under a `createMemoryHistory` (used in tests, or in some embedded
// contexts) it does not, and we'd leak the stale URL into the next
// `getRisonFilterParam()` call.
window.history.replaceState(
window.history.state,
'',
currentUrl.toString(),
);
if (history) {
history.replace({
pathname: currentUrl.pathname,
search: currentUrl.search,
});
}
} catch (error) {
console.warn('Failed to update URL with unmatched filters:', error);
}
}
/**
* Find a native filter that matches a Rison filter by column name.
* Uses case-insensitive, trimmed comparison to handle column names with spaces.
*/
function findMatchingNativeFilter(
risonFilter: RisonFilter,
nativeFilters: PartialFilters,
): string | null {
const normalizedSubject = risonFilter.subject.trim().toLowerCase();
for (const [filterId, nativeFilter] of Object.entries(nativeFilters)) {
if (!nativeFilter?.targets) continue;
const hasMatchingTarget = nativeFilter.targets.some(target => {
if (typeof target === 'object' && target && 'column' in target) {
return target.column?.name?.trim().toLowerCase() === normalizedSubject;
}
return false;
});
if (hasMatchingTarget) {
return filterId;
}
}
return null;
}
/**
* Build extraFormData filters for a given rison filter and column name
*/
function buildExtraFormDataFilters(
risonFilter: RisonFilter,
columnName: string,
): QueryObjectFilterClause[] {
const { operator, comparator } = risonFilter;
if (operator === 'IN' || (operator === '==' && Array.isArray(comparator))) {
return [
{
col: columnName,
op: 'IN',
val: Array.isArray(comparator) ? comparator : [comparator],
},
];
}
if (operator === '==' && !Array.isArray(comparator)) {
return [{ col: columnName, op: 'IN', val: [comparator] }];
}
if (
operator === 'BETWEEN' &&
Array.isArray(comparator) &&
comparator.length === 2
) {
return [
{ col: columnName, op: '>=', val: comparator[0] },
{ col: columnName, op: '<=', val: comparator[1] },
];
}
return [
{
col: columnName,
op: operator,
val: comparator,
} as QueryObjectFilterClause,
];
}
/**
* Convert a list of Rison filters into the `{col, op, val}` clauses expected
* by `dataMask[id].extraFormData.filters`. Each filter uses its own subject
* as the column name.
*/
export function risonFiltersToExtraFormDataFilters(
filters: RisonFilter[],
): QueryObjectFilterClause[] {
return filters.flatMap(filter =>
buildExtraFormDataFilters(filter, filter.subject),
);
}
/**
* Convert a Rison filter value to the format expected by a native filter.
* Also returns extraFormData for auto-application.
*/
function convertRisonToNativeValue(
risonFilter: RisonFilter,
nativeFilter: { filterType?: string },
): unknown {
const { comparator, operator } = risonFilter;
const filterType = nativeFilter?.filterType;
switch (filterType) {
case 'filter_select':
if (operator === 'IN' || Array.isArray(comparator)) {
return Array.isArray(comparator) ? comparator : [comparator];
}
return [comparator];
case 'filter_range':
if (
operator === 'BETWEEN' &&
Array.isArray(comparator) &&
comparator.length === 2
) {
return { min: comparator[0], max: comparator[1] };
}
return comparator;
case 'filter_time_range':
case 'filter_timecolumn':
return comparator;
default:
return Array.isArray(comparator) ? comparator : [comparator];
}
}
/**
* Build a complete DataMask entry for a rison filter matched to a native filter.
* Sets both filterState.value AND extraFormData so the filter auto-applies.
*/
function buildDataMaskForFilter(
risonFilter: RisonFilter,
nativeFilter: {
id: string;
filterType?: string;
targets?: { column?: { name?: string } }[];
},
columnName: string,
) {
const convertedValue = convertRisonToNativeValue(risonFilter, nativeFilter);
return {
id: nativeFilter.id,
filterState: {
value: convertedValue,
},
extraFormData: {
filters: buildExtraFormDataFilters(risonFilter, columnName),
},
ownState: {},
};
}
/**
* Intelligently inject Rison filters into native filters where possible,
* falling back to brute-force injection for unmatched filters
*/
export function injectRisonFiltersIntelligently(
risonFilters: RisonFilter[],
nativeFilters: PartialFilters,
currentDataMask: DataMaskStateWithId,
): IntelligentRisonInjectionResult {
const updatedDataMask = { ...currentDataMask };
const unmatchedFilters: RisonFilter[] = [];
risonFilters.forEach(risonFilter => {
const matchingFilterId = findMatchingNativeFilter(
risonFilter,
nativeFilters,
);
if (matchingFilterId) {
const matchedFilter = nativeFilters[matchingFilterId];
const filterId = matchedFilter?.id ?? matchingFilterId;
if (matchedFilter && filterId) {
const columnName =
matchedFilter.targets?.[0]?.column?.name ?? risonFilter.subject;
const dataMaskEntry = buildDataMaskForFilter(
risonFilter,
{ ...matchedFilter, id: filterId } as {
id: string;
filterType?: string;
targets?: { column?: { name?: string } }[];
},
columnName,
);
updatedDataMask[filterId] = {
...updatedDataMask[filterId],
...dataMaskEntry,
};
return;
}
}
unmatchedFilters.push(risonFilter);
});
return {
updatedDataMask,
unmatchedFilters,
};
}

View File

@@ -0,0 +1,274 @@
# 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.
"""
Parser for Rison URL filters that converts simplified filter syntax
to Superset's adhoc_filters format.
"""
from __future__ import annotations
import logging
import re
from typing import Any, Optional, Union
import prison
from flask import has_request_context, request
logger = logging.getLogger(__name__)
# SQL identifiers permitted in URL-supplied filter columns. Restricted to
# alphanumeric/underscore (optionally schema-qualified) so OR-path SQL
# generation never interpolates an attacker-controlled identifier verbatim.
_SAFE_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$")
def _safe_identifier(name: Any) -> Optional[str]:
if isinstance(name, str) and _SAFE_IDENTIFIER_RE.match(name):
return name
return None
def _quote_sql_literal(value: Any) -> Optional[str]:
"""Quote a scalar value safely for SQL string interpolation.
- Strings: single-quoted with `'` escaped as `''` (SQL standard).
- Numeric / bool: rendered bare.
- Anything else (None, dict, list, etc.): rejected.
"""
if isinstance(value, bool):
return "TRUE" if value else "FALSE"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, str):
return "'" + value.replace("'", "''") + "'"
return None
class RisonFilterParser:
"""
Parse Rison filter syntax from URL parameter 'f' and convert to adhoc_filters.
Supports:
- Simple equality: f=(country:USA)
- Lists (IN): f=(country:!(USA,Canada))
- NOT operator: f=(NOT:(country:USA))
- OR operator: f=(OR:!(condition1,condition2))
- Comparison operators: f=(sales:(gt:100000))
- BETWEEN: f=(date:(between:!(2024-01-01,2024-12-31)))
- LIKE: f=(name:(like:'%smith%'))
"""
OPERATORS: dict[str, str] = {
"gt": ">",
"gte": ">=",
"lt": "<",
"lte": "<=",
"between": "BETWEEN",
"like": "LIKE",
"ilike": "ILIKE",
"ne": "!=",
"eq": "==",
}
def parse(self, filter_string: Optional[str] = None) -> list[dict[str, Any]]:
"""
Parse Rison filter string and convert to adhoc_filters format.
Args:
filter_string: Rison-encoded filter string, or None to get from request
Returns:
List of adhoc_filter dictionaries
"""
if filter_string is None:
# Callers outside a Flask request context (CLI, async tasks, tests)
# would otherwise hit a RuntimeError on `request.args` access.
if not has_request_context():
return []
filter_string = request.args.get("f")
if not filter_string:
return []
try:
filters_obj = prison.loads(filter_string)
return self._convert_to_adhoc_filters(filters_obj)
except Exception:
logger.warning(
"Failed to parse Rison filters: %s", filter_string, exc_info=True
)
return []
def _convert_to_adhoc_filters(
self, filters_obj: Union[dict[str, Any], list[Any], Any]
) -> list[dict[str, Any]]:
if not isinstance(filters_obj, dict):
return []
adhoc_filters: list[dict[str, Any]] = []
for key, value in filters_obj.items():
if key == "OR":
adhoc_filters.extend(self._handle_or_operator(value))
elif key == "NOT":
adhoc_filters.extend(self._handle_not_operator(value))
else:
filter_dict = self._create_filter(key, value)
if filter_dict:
adhoc_filters.append(filter_dict)
return adhoc_filters
def _create_filter(
self, column: str, value: Any, negate: bool = False
) -> Optional[dict[str, Any]]:
filter_dict: dict[str, Any] = {
"expressionType": "SIMPLE",
"clause": "WHERE",
"subject": column,
}
if isinstance(value, list):
filter_dict["operator"] = "NOT IN" if negate else "IN"
filter_dict["comparator"] = value
elif isinstance(value, dict):
operator_info = self._parse_operator_dict(value)
if operator_info:
operator, comparator = operator_info
if negate and operator == "==":
operator = "!="
elif negate and operator == "IN":
operator = "NOT IN"
filter_dict["operator"] = operator
filter_dict["comparator"] = comparator
else:
return None
else:
filter_dict["operator"] = "!=" if negate else "=="
filter_dict["comparator"] = value
return filter_dict
def _parse_operator_dict(
self, op_dict: dict[str, Any]
) -> Optional[tuple[str, Any]]:
if not op_dict:
return None
for op_key, op_value in op_dict.items():
if op_key in self.OPERATORS:
operator = self.OPERATORS[op_key]
if (
operator == "BETWEEN"
and isinstance(op_value, list)
and len(op_value) == 2
):
return operator, op_value
return operator, op_value
if op_key == "in":
return "IN", op_value if isinstance(op_value, list) else [op_value]
if op_key == "nin":
return "NOT IN", op_value if isinstance(op_value, list) else [op_value]
return None
def _handle_or_operator(self, or_value: Any) -> list[dict[str, Any]]:
if not isinstance(or_value, list):
return []
sql_parts: list[str] = []
for item in or_value:
if isinstance(item, dict):
for col, val in item.items():
if col not in ("OR", "NOT"):
sql_part = self._build_sql_condition(col, val)
if sql_part:
sql_parts.append(sql_part)
if sql_parts:
return [
{
"expressionType": "SQL",
"clause": "WHERE",
"sqlExpression": f"({' OR '.join(sql_parts)})",
}
]
return []
def _build_sql_condition(self, column: str, value: Any) -> Optional[str]:
# URL-supplied columns flow directly into a raw SQL expression in the
# OR path, so the identifier must match a strict whitelist or we drop
# the whole condition. Likewise, string literals get `'` escaped to
# `''` and unrecognised value types are rejected outright.
safe_column = _safe_identifier(column)
if safe_column is None:
logger.warning("Rejecting URL filter with unsafe column: %r", column)
return None
if isinstance(value, list):
quoted = [_quote_sql_literal(v) for v in value]
if any(q is None for q in quoted):
return None
return f"{safe_column} IN ({', '.join(quoted)})" # type: ignore[arg-type]
if isinstance(value, dict):
operator_info = self._parse_operator_dict(value)
if operator_info:
op, comp = operator_info
if op == "BETWEEN" and isinstance(comp, list) and len(comp) == 2:
lo = _quote_sql_literal(comp[0])
hi = _quote_sql_literal(comp[1])
if lo is None or hi is None:
return None
return f"{safe_column} BETWEEN {lo} AND {hi}"
comp_str = _quote_sql_literal(comp)
if comp_str is None:
return None
return f"{safe_column} {op} {comp_str}"
return None
val_str = _quote_sql_literal(value)
if val_str is None:
return None
return f"{safe_column} = {val_str}"
def _handle_not_operator(self, not_value: Any) -> list[dict[str, Any]]:
if isinstance(not_value, dict):
filters: list[dict[str, Any]] = []
for col, val in not_value.items():
if col not in ("OR", "NOT"):
filter_dict = self._create_filter(col, val, negate=True)
if filter_dict:
filters.append(filter_dict)
return filters
return []
def merge_rison_filters(form_data: dict[str, Any]) -> None:
"""
Merge Rison filters from 'f' parameter into form_data.
Modifies form_data in place.
"""
parser = RisonFilterParser()
if rison_filters := parser.parse():
existing_filters = form_data.get("adhoc_filters", [])
form_data["adhoc_filters"] = existing_filters + rison_filters
logger.info("Added %d filters from Rison parameter", len(rison_filters))

View File

@@ -0,0 +1,183 @@
# 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.
"""Unit tests for Rison filter parser."""
from superset.utils.rison_filters import RisonFilterParser
def test_simple_equality():
parser = RisonFilterParser()
result = parser.parse("(country:USA)")
assert len(result) == 1
assert result[0]["expressionType"] == "SIMPLE"
assert result[0]["clause"] == "WHERE"
assert result[0]["subject"] == "country"
assert result[0]["operator"] == "=="
assert result[0]["comparator"] == "USA"
def test_multiple_filters_and():
parser = RisonFilterParser()
result = parser.parse("(country:USA,year:2024)")
assert len(result) == 2
assert result[0]["subject"] == "country"
assert result[0]["comparator"] == "USA"
assert result[1]["subject"] == "year"
assert result[1]["comparator"] == 2024
def test_list_in_operator():
parser = RisonFilterParser()
result = parser.parse("(country:!(USA,Canada))")
assert len(result) == 1
assert result[0]["subject"] == "country"
assert result[0]["operator"] == "IN"
assert result[0]["comparator"] == ["USA", "Canada"]
def test_not_operator():
parser = RisonFilterParser()
result = parser.parse("(NOT:(country:USA))")
assert len(result) == 1
assert result[0]["subject"] == "country"
assert result[0]["operator"] == "!="
assert result[0]["comparator"] == "USA"
def test_not_in_operator():
parser = RisonFilterParser()
result = parser.parse("(NOT:(country:!(USA,Canada)))")
assert len(result) == 1
assert result[0]["subject"] == "country"
assert result[0]["operator"] == "NOT IN"
assert result[0]["comparator"] == ["USA", "Canada"]
def test_or_operator():
parser = RisonFilterParser()
result = parser.parse("(OR:!((status:active),(priority:high)))")
assert len(result) == 1
assert result[0]["expressionType"] == "SQL"
assert result[0]["clause"] == "WHERE"
assert "status = 'active' OR priority = 'high'" in result[0]["sqlExpression"]
def test_comparison_operators():
parser = RisonFilterParser()
result = parser.parse("(sales:(gt:100000))")
assert result[0]["operator"] == ">"
assert result[0]["comparator"] == 100000
result = parser.parse("(age:(gte:18))")
assert result[0]["operator"] == ">="
assert result[0]["comparator"] == 18
result = parser.parse("(temp:(lt:32))")
assert result[0]["operator"] == "<"
assert result[0]["comparator"] == 32
result = parser.parse("(price:(lte:1000))")
assert result[0]["operator"] == "<="
assert result[0]["comparator"] == 1000
def test_between_operator():
parser = RisonFilterParser()
result = parser.parse("(date:(between:!('2024-01-01','2024-12-31')))")
assert len(result) == 1
assert result[0]["operator"] == "BETWEEN"
assert result[0]["comparator"] == ["2024-01-01", "2024-12-31"]
def test_like_operator():
parser = RisonFilterParser()
result = parser.parse("(name:(like:'%smith%'))")
assert len(result) == 1
assert result[0]["operator"] == "LIKE"
assert result[0]["comparator"] == "%smith%"
def test_empty_filter():
parser = RisonFilterParser()
assert parser.parse("") == []
assert parser.parse("()") == []
def test_invalid_rison():
parser = RisonFilterParser()
assert parser.parse("invalid rison") == []
assert parser.parse("(unclosed") == []
def test_or_branch_escapes_string_literals():
"""OR-branch SQL must escape apostrophes; otherwise a value like
`O'Brien'); DROP TABLE x; --` could close out a literal and inject.
Bypasses the Rison parser (whose quoting rules complicate expressing
apostrophes in test inputs) and exercises `_handle_or_operator` with
a hand-built decoded payload — which is exactly what `_convert_to_adhoc_filters`
feeds it after a successful prison.loads.
"""
parser = RisonFilterParser()
result = parser._handle_or_operator(
[{"name": "O'Brien"}, {"role": "admin"}],
)
assert len(result) == 1
sql = result[0]["sqlExpression"]
# Single quote inside the literal is doubled per SQL standard.
assert "'O''Brien'" in sql
assert "'admin'" in sql
def test_or_branch_rejects_unsafe_identifiers():
"""Columns that don't match a strict identifier whitelist drop the
condition rather than getting interpolated into SQL."""
parser = RisonFilterParser()
result = parser._handle_or_operator(
[{"col; DROP TABLE x": 1}, {"role": "admin"}],
)
# The unsafe column is dropped, the safe one remains.
assert len(result) == 1
sql = result[0]["sqlExpression"]
assert "DROP" not in sql
assert "role = 'admin'" in sql
def test_or_branch_all_conditions_unsafe_returns_nothing():
parser = RisonFilterParser()
result = parser._handle_or_operator(
[{"col;1": 1}, {"col;2": 2}],
)
assert result == []
def test_or_branch_between_quotes_both_bounds():
parser = RisonFilterParser()
result = parser.parse("(OR:!((date:(between:!('2024-01-01','2024-12-31')))))")
sql = result[0]["sqlExpression"]
assert "date BETWEEN '2024-01-01' AND '2024-12-31'" in sql