mirror of
https://github.com/apache/superset.git
synced 2026-08-12 11:11:01 +00:00
feat(dashboard): add Rison-encoded URL filter support (#39795)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
7e8b8e25a5
commit
9a79588d35
+123
-14
@@ -17,20 +17,39 @@
|
|||||||
* under the License.
|
* 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 { 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 { styled } from '@apache-superset/core/theme';
|
||||||
import { Loading } from '@superset-ui/core/components';
|
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 { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
|
||||||
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
|
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 FilterControls from './FilterControls/FilterControls';
|
||||||
import { useChartsVerboseMaps, getFilterBarTestId } from './utils';
|
import { useChartsVerboseMaps, getFilterBarTestId } from './utils';
|
||||||
import { HorizontalBarProps } from './types';
|
import { HorizontalBarProps } from './types';
|
||||||
import FilterBarSettings from './FilterBarSettings';
|
import FilterBarSettings from './FilterBarSettings';
|
||||||
import crossFiltersSelector from './CrossFilters/selectors';
|
import crossFiltersSelector from './CrossFilters/selectors';
|
||||||
|
import {
|
||||||
|
getUrlFilterIndicators,
|
||||||
|
getUrlFilterIdentity,
|
||||||
|
UrlFilterIndicator,
|
||||||
|
} from './UrlFilters/urlFilterUtils';
|
||||||
|
import UrlFilterTag from './UrlFilters/UrlFilterTag';
|
||||||
|
|
||||||
const HorizontalBar = styled.div`
|
const HorizontalBar = styled.div`
|
||||||
${({ theme }) => `
|
${({ 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> = ({
|
const HorizontalFilterBar: FC<HorizontalBarProps> = ({
|
||||||
actions,
|
actions,
|
||||||
dataMaskSelected,
|
dataMaskSelected,
|
||||||
@@ -79,6 +120,9 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
|
|||||||
const dataMask = useSelector<RootState, DataMaskStateWithId>(
|
const dataMask = useSelector<RootState, DataMaskStateWithId>(
|
||||||
state => state.dataMask,
|
state => state.dataMask,
|
||||||
);
|
);
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const history = useHistory();
|
||||||
|
const location = useLocation();
|
||||||
const chartIds = useChartIds();
|
const chartIds = useChartIds();
|
||||||
const chartLayoutItems = useChartLayoutItems();
|
const chartLayoutItems = useChartLayoutItems();
|
||||||
const verboseMaps = useChartsVerboseMaps();
|
const verboseMaps = useChartsVerboseMaps();
|
||||||
@@ -94,9 +138,71 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
|
|||||||
[chartIds, chartLayoutItems, dataMask, verboseMaps],
|
[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 =
|
const hasFilters =
|
||||||
filterValues.length > 0 ||
|
filterValues.length > 0 ||
|
||||||
selectedCrossFilters.length > 0 ||
|
selectedCrossFilters.length > 0 ||
|
||||||
|
activeUrlFilters.length > 0 ||
|
||||||
chartCustomizationValues.length > 0;
|
chartCustomizationValues.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -113,16 +219,19 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
|
|||||||
</FilterBarEmptyStateContainer>
|
</FilterBarEmptyStateContainer>
|
||||||
)}
|
)}
|
||||||
{hasFilters && (
|
{hasFilters && (
|
||||||
<FilterControls
|
<>
|
||||||
dataMaskSelected={dataMaskSelected}
|
{urlFiltersComponent}
|
||||||
onFilterSelectionChange={onSelectionChange}
|
<FilterControls
|
||||||
onPendingCustomizationDataMaskChange={
|
dataMaskSelected={dataMaskSelected}
|
||||||
onPendingCustomizationDataMaskChange
|
onFilterSelectionChange={onSelectionChange}
|
||||||
}
|
onPendingCustomizationDataMaskChange={
|
||||||
chartCustomizationValues={chartCustomizationValues}
|
onPendingCustomizationDataMaskChange
|
||||||
clearAllTriggers={clearAllTriggers}
|
}
|
||||||
onClearAllComplete={onClearAllComplete}
|
chartCustomizationValues={chartCustomizationValues}
|
||||||
/>
|
clearAllTriggers={clearAllTriggers}
|
||||||
|
onClearAllComplete={onClearAllComplete}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{actions}
|
{actions}
|
||||||
</>
|
</>
|
||||||
|
|||||||
+1
@@ -36,6 +36,7 @@ const renderWrapper = (overrideProps?: Record<string, any>) =>
|
|||||||
waitFor(() =>
|
waitFor(() =>
|
||||||
render(<HorizontalBar {...defaultProps} {...overrideProps} />, {
|
render(<HorizontalBar {...defaultProps} {...overrideProps} />, {
|
||||||
useRedux: true,
|
useRedux: true,
|
||||||
|
useRouter: true,
|
||||||
initialState: {
|
initialState: {
|
||||||
dashboardState: {
|
dashboardState: {
|
||||||
sliceIds: [],
|
sliceIds: [],
|
||||||
|
|||||||
+84
@@ -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;
|
||||||
+139
@@ -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();
|
||||||
|
});
|
||||||
+96
@@ -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;
|
||||||
+131
@@ -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;
|
||||||
+71
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -45,6 +45,7 @@ import Header from './Header';
|
|||||||
import FilterControls from './FilterControls/FilterControls';
|
import FilterControls from './FilterControls/FilterControls';
|
||||||
import CrossFiltersVertical from './CrossFilters/Vertical';
|
import CrossFiltersVertical from './CrossFilters/Vertical';
|
||||||
import crossFiltersSelector from './CrossFilters/selectors';
|
import crossFiltersSelector from './CrossFilters/selectors';
|
||||||
|
import UrlFiltersVertical from './UrlFilters/Vertical';
|
||||||
|
|
||||||
enum SectionType {
|
enum SectionType {
|
||||||
Filters = 'filters',
|
Filters = 'filters',
|
||||||
@@ -301,6 +302,7 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
|
|||||||
) : (
|
) : (
|
||||||
<div css={tabPaneStyle} onScroll={onScroll}>
|
<div css={tabPaneStyle} onScroll={onScroll}>
|
||||||
<>
|
<>
|
||||||
|
<UrlFiltersVertical />
|
||||||
<CrossFiltersVertical hideHeader={hasOnlyOneSectionType} />
|
<CrossFiltersVertical hideHeader={hasOnlyOneSectionType} />
|
||||||
{filterControls}
|
{filterControls}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -107,8 +107,14 @@ const publishDataMask = debounce(
|
|||||||
const previousParams = new URLSearchParams(search);
|
const previousParams = new URLSearchParams(search);
|
||||||
const newParams = new URLSearchParams();
|
const newParams = new URLSearchParams();
|
||||||
let dataMaskKey: string | null;
|
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) => {
|
previousParams.forEach((value, key) => {
|
||||||
if (!EXCLUDED_URL_PARAMS.includes(key)) {
|
if (!EXCLUDED_URL_PARAMS.includes(key) && key !== 'f') {
|
||||||
newParams.append(key, value);
|
newParams.append(key, value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -148,9 +154,16 @@ const publishDataMask = debounce(
|
|||||||
if (appRoot !== '/' && replacementPathname.startsWith(appRoot)) {
|
if (appRoot !== '/' && replacementPathname.startsWith(appRoot)) {
|
||||||
replacementPathname = replacementPathname.substring(appRoot.length);
|
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({
|
history.replace({
|
||||||
pathname: replacementPathname,
|
pathname: replacementPathname,
|
||||||
search: newParams.toString(),
|
search: searchString,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -64,6 +64,18 @@ import SyncDashboardState, {
|
|||||||
getDashboardContextLocalStorage,
|
getDashboardContextLocalStorage,
|
||||||
} from '../components/SyncDashboardState';
|
} from '../components/SyncDashboardState';
|
||||||
import { AutoRefreshProvider } from '../contexts/AutoRefreshContext';
|
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('');
|
export const DashboardPageIdContext = createContext('');
|
||||||
|
|
||||||
@@ -195,6 +207,63 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
|||||||
dataMask = isOldRison;
|
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 (readyToRender) {
|
||||||
if (!isDashboardHydrated.current) {
|
if (!isDashboardHydrated.current) {
|
||||||
isDashboardHydrated.current = true;
|
isDashboardHydrated.current = true;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user