mirror of
https://github.com/apache/superset.git
synced 2026-05-07 08:54:23 +00:00
feat(dashboard): add Rison-encoded URL filter support
Adds ?f=(...) URL parameter support for hydrating dashboard filters from human-readable Rison syntax. Matches URL filters to native filters by column name (case-insensitive, handles spaces) and auto-applies them by populating both filterState.value and extraFormData. Unmatched filters render as removable chips in a "URL Filters" section above cross-filters. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -17,20 +17,31 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { FC, memo, useMemo } from 'react';
|
||||
import { FC, memo, useCallback, useMemo } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { DataMaskStateWithId } from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Loading } from '@superset-ui/core/components';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { FilterBarOrientation, RootState } from 'src/dashboard/types';
|
||||
import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
|
||||
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
getRisonFilterParam,
|
||||
parseRisonFilters,
|
||||
updateUrlWithUnmatchedFilters,
|
||||
} from 'src/dashboard/util/risonFilters';
|
||||
import FilterControls from './FilterControls/FilterControls';
|
||||
import { useChartsVerboseMaps, getFilterBarTestId } from './utils';
|
||||
import { HorizontalBarProps } from './types';
|
||||
import FilterBarSettings from './FilterBarSettings';
|
||||
import crossFiltersSelector from './CrossFilters/selectors';
|
||||
import {
|
||||
getUrlFilterIndicators,
|
||||
UrlFilterIndicator,
|
||||
} from './UrlFilters/selectors';
|
||||
import UrlFilterTag from './UrlFilters/UrlFilterTag';
|
||||
|
||||
const HorizontalBar = styled.div`
|
||||
${({ theme }) => `
|
||||
@@ -65,6 +76,28 @@ const FilterBarEmptyStateContainer = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
const UrlFiltersContainer = styled.div`
|
||||
${({ theme }) => `
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
padding: 0 ${theme.sizeUnit * 2}px;
|
||||
margin-right: ${theme.sizeUnit * 2}px;
|
||||
border-right: 1px solid ${theme.colorBorder};
|
||||
`}
|
||||
`;
|
||||
|
||||
const UrlFilterTitle = styled.div`
|
||||
${({ theme }) => `
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const HorizontalFilterBar: FC<HorizontalBarProps> = ({
|
||||
actions,
|
||||
dataMaskSelected,
|
||||
@@ -94,9 +127,47 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
|
||||
[chartIds, chartLayoutItems, dataMask, verboseMaps],
|
||||
);
|
||||
|
||||
const activeUrlFilters = useMemo(() => getUrlFilterIndicators(), []);
|
||||
|
||||
const handleRemoveUrlFilter = useCallback(
|
||||
(filterToRemove: UrlFilterIndicator) => {
|
||||
const risonParam = getRisonFilterParam();
|
||||
if (!risonParam) return;
|
||||
|
||||
const currentFilters = parseRisonFilters(risonParam);
|
||||
const remaining = currentFilters.filter(
|
||||
f => f.subject !== filterToRemove.filter.subject,
|
||||
);
|
||||
updateUrlWithUnmatchedFilters(remaining);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
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={filter.subject}
|
||||
filter={filter}
|
||||
orientation={FilterBarOrientation.Horizontal}
|
||||
onRemove={handleRemoveUrlFilter}
|
||||
/>
|
||||
))}
|
||||
</UrlFiltersContainer>
|
||||
);
|
||||
}, [activeUrlFilters, handleRemoveUrlFilter]);
|
||||
|
||||
const hasFilters =
|
||||
filterValues.length > 0 ||
|
||||
selectedCrossFilters.length > 0 ||
|
||||
activeUrlFilters.length > 0 ||
|
||||
chartCustomizationValues.length > 0;
|
||||
|
||||
return (
|
||||
@@ -113,16 +184,19 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
|
||||
</FilterBarEmptyStateContainer>
|
||||
)}
|
||||
{hasFilters && (
|
||||
<FilterControls
|
||||
dataMaskSelected={dataMaskSelected}
|
||||
onFilterSelectionChange={onSelectionChange}
|
||||
onPendingCustomizationDataMaskChange={
|
||||
onPendingCustomizationDataMaskChange
|
||||
}
|
||||
chartCustomizationValues={chartCustomizationValues}
|
||||
clearAllTriggers={clearAllTriggers}
|
||||
onClearAllComplete={onClearAllComplete}
|
||||
/>
|
||||
<>
|
||||
{urlFiltersComponent}
|
||||
<FilterControls
|
||||
dataMaskSelected={dataMaskSelected}
|
||||
onFilterSelectionChange={onSelectionChange}
|
||||
onPendingCustomizationDataMaskChange={
|
||||
onPendingCustomizationDataMaskChange
|
||||
}
|
||||
chartCustomizationValues={chartCustomizationValues}
|
||||
clearAllTriggers={clearAllTriggers}
|
||||
onClearAllComplete={onClearAllComplete}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{actions}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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 './selectors';
|
||||
|
||||
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)}
|
||||
editable
|
||||
>
|
||||
<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;
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* 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 { useMemo } from 'react';
|
||||
import { getUrlFilterIndicators } from './selectors';
|
||||
import UrlFiltersVerticalCollapse from './VerticalCollapse';
|
||||
|
||||
const UrlFiltersVertical = () => {
|
||||
const urlFilters = useMemo(() => getUrlFilterIndicators(), []);
|
||||
|
||||
if (!urlFilters.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <UrlFiltersVerticalCollapse urlFilters={urlFilters} />;
|
||||
};
|
||||
|
||||
export default UrlFiltersVertical;
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 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 { useMemo, useState, useCallback } 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 {
|
||||
updateUrlWithUnmatchedFilters,
|
||||
getRisonFilterParam,
|
||||
parseRisonFilters,
|
||||
} from 'src/dashboard/util/risonFilters';
|
||||
import UrlFilterTag from './UrlFilterTag';
|
||||
import { UrlFilterIndicator } from './selectors';
|
||||
|
||||
const UrlFiltersVerticalCollapse = (props: {
|
||||
urlFilters: UrlFilterIndicator[];
|
||||
}) => {
|
||||
const { urlFilters: initialFilters } = props;
|
||||
const theme = useTheme();
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [urlFilters, setUrlFilters] =
|
||||
useState<UrlFilterIndicator[]>(initialFilters);
|
||||
|
||||
const toggleSection = useCallback(() => {
|
||||
setIsOpen(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const handleRemoveFilter = useCallback(
|
||||
(filterToRemove: UrlFilterIndicator) => {
|
||||
const risonParam = getRisonFilterParam();
|
||||
if (!risonParam) return;
|
||||
|
||||
const currentFilters = parseRisonFilters(risonParam);
|
||||
const remaining = currentFilters.filter(
|
||||
f => f.subject !== filterToRemove.filter.subject,
|
||||
);
|
||||
|
||||
updateUrlWithUnmatchedFilters(remaining);
|
||||
setUrlFilters(prev =>
|
||||
prev.filter(f => f.subject !== filterToRemove.subject),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const sectionContainerStyle = useCallback(
|
||||
(theme: SupersetTheme) => css`
|
||||
margin-bottom: ${theme.sizeUnit * 3}px;
|
||||
padding: 0 ${theme.sizeUnit * 4}px;
|
||||
`,
|
||||
[],
|
||||
);
|
||||
|
||||
const sectionHeaderStyle = useCallback(
|
||||
(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 = useCallback(
|
||||
(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 = useCallback(
|
||||
(theme: SupersetTheme) => css`
|
||||
padding: ${theme.sizeUnit * 2}px 0;
|
||||
`,
|
||||
[],
|
||||
);
|
||||
|
||||
const dividerStyle = useCallback(
|
||||
(theme: SupersetTheme) => css`
|
||||
height: 1px;
|
||||
background: ${theme.colorSplit};
|
||||
margin: ${theme.sizeUnit * 2}px 0;
|
||||
`,
|
||||
[],
|
||||
);
|
||||
|
||||
const iconStyle = useCallback(
|
||||
(open: boolean, theme: SupersetTheme) => css`
|
||||
transform: ${open ? 'rotate(0deg)' : 'rotate(180deg)'};
|
||||
transition: transform 0.2s ease;
|
||||
color: ${theme.colorTextSecondary};
|
||||
`,
|
||||
[],
|
||||
);
|
||||
|
||||
const filterIndicators = useMemo(
|
||||
() =>
|
||||
urlFilters.map(filter => (
|
||||
<UrlFilterTag
|
||||
key={filter.subject}
|
||||
filter={filter}
|
||||
orientation={FilterBarOrientation.Vertical}
|
||||
onRemove={handleRemoveFilter}
|
||||
/>
|
||||
)),
|
||||
[urlFilters, handleRemoveFilter],
|
||||
);
|
||||
|
||||
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;
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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 CrossFiltersVertical from './CrossFilters/Vertical';
|
||||
import crossFiltersSelector from './CrossFilters/selectors';
|
||||
import UrlFiltersVertical from './UrlFilters/Vertical';
|
||||
|
||||
enum SectionType {
|
||||
Filters = 'filters',
|
||||
@@ -301,6 +302,7 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
|
||||
) : (
|
||||
<div css={tabPaneStyle} onScroll={onScroll}>
|
||||
<>
|
||||
<UrlFiltersVertical />
|
||||
<CrossFiltersVertical hideHeader={hasOnlyOneSectionType} />
|
||||
{filterControls}
|
||||
</>
|
||||
|
||||
@@ -107,9 +107,16 @@ const publishDataMask = debounce(
|
||||
const previousParams = new URLSearchParams(search);
|
||||
const newParams = new URLSearchParams();
|
||||
let dataMaskKey: string | null;
|
||||
let risonFilterValue: string | null = null;
|
||||
|
||||
previousParams.forEach((value, key) => {
|
||||
if (!EXCLUDED_URL_PARAMS.includes(key)) {
|
||||
newParams.append(key, value);
|
||||
if (key === 'f') {
|
||||
// Preserve the original Rison filter value to avoid encoding
|
||||
risonFilterValue = value;
|
||||
} else {
|
||||
newParams.append(key, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -148,9 +155,16 @@ const publishDataMask = debounce(
|
||||
if (appRoot !== '/' && replacementPathname.startsWith(appRoot)) {
|
||||
replacementPathname = replacementPathname.substring(appRoot.length);
|
||||
}
|
||||
// Manually reconstruct the search string to preserve Rison filter encoding
|
||||
let searchString = newParams.toString();
|
||||
if (risonFilterValue) {
|
||||
const separator = searchString ? '&' : '';
|
||||
searchString = `${searchString}${separator}f=${risonFilterValue}`;
|
||||
}
|
||||
|
||||
history.replace({
|
||||
pathname: replacementPathname,
|
||||
search: newParams.toString(),
|
||||
search: searchString,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -64,6 +64,15 @@ import SyncDashboardState, {
|
||||
getDashboardContextLocalStorage,
|
||||
} from '../components/SyncDashboardState';
|
||||
import { AutoRefreshProvider } from '../contexts/AutoRefreshContext';
|
||||
import { PartialFilters } from '@superset-ui/core';
|
||||
import {
|
||||
parseRisonFilters,
|
||||
risonToAdhocFilters,
|
||||
getRisonFilterParam,
|
||||
prettifyRisonFilterUrl,
|
||||
injectRisonFiltersIntelligently,
|
||||
updateUrlWithUnmatchedFilters,
|
||||
} from '../util/risonFilters';
|
||||
|
||||
export const DashboardPageIdContext = createContext('');
|
||||
|
||||
@@ -195,6 +204,61 @@ export const DashboardPage: FC<PageProps> = ({ idOrSlug }: PageProps) => {
|
||||
dataMask = isOldRison;
|
||||
}
|
||||
|
||||
// Parse Rison URL filters with intelligent native filter injection
|
||||
const risonFilterParam = getRisonFilterParam();
|
||||
if (risonFilterParam) {
|
||||
const risonFilters = parseRisonFilters(risonFilterParam);
|
||||
if (risonFilters.length > 0) {
|
||||
// Convert native filter config array to keyed object for lookup
|
||||
const filterConfigArray =
|
||||
(dashboard?.metadata
|
||||
?.native_filter_configuration as Array<Record<string, unknown> & { id: string }>) ||
|
||||
[];
|
||||
const nativeFilters: PartialFilters = {};
|
||||
filterConfigArray.forEach(filter => {
|
||||
nativeFilters[filter.id] = filter as PartialFilters[string];
|
||||
});
|
||||
const injectionResult = injectRisonFiltersIntelligently(
|
||||
risonFilters,
|
||||
nativeFilters,
|
||||
dataMask,
|
||||
);
|
||||
|
||||
dataMask = injectionResult.updatedDataMask;
|
||||
|
||||
// For unmatched filters, fall back to adhoc filter approach
|
||||
if (injectionResult.unmatchedFilters.length > 0) {
|
||||
const unmatchedAdhocFilters = risonToAdhocFilters(
|
||||
injectionResult.unmatchedFilters,
|
||||
);
|
||||
|
||||
const risonDataMask = {
|
||||
__rison_filters__: {
|
||||
filterState: { value: unmatchedAdhocFilters },
|
||||
ownState: {},
|
||||
},
|
||||
};
|
||||
|
||||
dataMask = { ...dataMask, ...risonDataMask };
|
||||
}
|
||||
|
||||
// Clean up URL: remove matched filters, keep only unmatched ones
|
||||
const matchedCount =
|
||||
risonFilters.length - injectionResult.unmatchedFilters.length;
|
||||
if (matchedCount > 0) {
|
||||
setTimeout(
|
||||
() =>
|
||||
updateUrlWithUnmatchedFilters(injectionResult.unmatchedFilters),
|
||||
100,
|
||||
);
|
||||
}
|
||||
|
||||
if (injectionResult.unmatchedFilters.length > 0) {
|
||||
setTimeout(() => prettifyRisonFilterUrl(), 150);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (readyToRender) {
|
||||
if (!isDashboardHydrated.current) {
|
||||
isDashboardHydrated.current = true;
|
||||
|
||||
325
superset-frontend/src/dashboard/util/risonFilters.test.ts
Normal file
325
superset-frontend/src/dashboard/util/risonFilters.test.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* 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,
|
||||
risonFiltersToString,
|
||||
risonToAdhocFilters,
|
||||
} 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('');
|
||||
});
|
||||
490
superset-frontend/src/dashboard/util/risonFilters.ts
Normal file
490
superset-frontend/src/dashboard/util/risonFilters.ts
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
export interface RisonFilter {
|
||||
subject: string;
|
||||
operator: string;
|
||||
comparator: string | number | boolean | (string | number)[];
|
||||
}
|
||||
|
||||
export interface IntelligentRisonInjectionResult {
|
||||
updatedDataMask: DataMaskStateWithId;
|
||||
unmatchedFilters: RisonFilter[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
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);
|
||||
},
|
||||
);
|
||||
return filters;
|
||||
}
|
||||
|
||||
// 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 '';
|
||||
}
|
||||
|
||||
const risonObject: Record<
|
||||
string,
|
||||
string | number | boolean | (string | number)[] | Record<string, unknown>
|
||||
> = {};
|
||||
|
||||
filters.forEach(filter => {
|
||||
if (filter.operator === 'IN' && Array.isArray(filter.comparator)) {
|
||||
risonObject[filter.subject] = filter.comparator;
|
||||
} else if (filter.operator === '==') {
|
||||
risonObject[filter.subject] = filter.comparator;
|
||||
} else {
|
||||
const operatorMap: Record<string, string> = {
|
||||
'>': 'gt',
|
||||
'>=': 'gte',
|
||||
'<': 'lt',
|
||||
'<=': 'lte',
|
||||
BETWEEN: 'between',
|
||||
LIKE: 'like',
|
||||
};
|
||||
|
||||
const risonOp = operatorMap[filter.operator] || filter.operator;
|
||||
risonObject[filter.subject] = { [risonOp]: filter.comparator };
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
return rison.encode(risonObject);
|
||||
} catch (error) {
|
||||
console.warn('Failed to encode Rison filters:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the URL to remove successfully matched filters, keeping only unmatched ones
|
||||
*/
|
||||
export function updateUrlWithUnmatchedFilters(
|
||||
unmatchedFilters: RisonFilter[],
|
||||
): 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');
|
||||
}
|
||||
}
|
||||
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
'',
|
||||
currentUrl.toString(),
|
||||
);
|
||||
} 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,
|
||||
): { col: string; op: string; val: unknown }[] {
|
||||
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 }];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
if (matchedFilter) {
|
||||
const columnName =
|
||||
matchedFilter.targets?.[0]?.column?.name ?? risonFilter.subject;
|
||||
|
||||
const dataMaskEntry = buildDataMaskForFilter(
|
||||
risonFilter,
|
||||
matchedFilter as { id: string; filterType?: string; targets?: { column?: { name?: string } }[] },
|
||||
columnName,
|
||||
);
|
||||
|
||||
updatedDataMask[matchedFilter.id] = {
|
||||
...updatedDataMask[matchedFilter.id],
|
||||
...dataMaskEntry,
|
||||
};
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
unmatchedFilters.push(risonFilter);
|
||||
});
|
||||
|
||||
return {
|
||||
updatedDataMask,
|
||||
unmatchedFilters,
|
||||
};
|
||||
}
|
||||
226
superset/utils/rison_filters.py
Normal file
226
superset/utils/rison_filters.py
Normal file
@@ -0,0 +1,226 @@
|
||||
# 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
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
import prison
|
||||
from flask import request
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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:
|
||||
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]:
|
||||
if isinstance(value, list):
|
||||
values_str = ", ".join(
|
||||
[f"'{v}'" if isinstance(v, str) else str(v) for v in value]
|
||||
)
|
||||
return f"{column} IN ({values_str})"
|
||||
|
||||
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):
|
||||
return f"{column} BETWEEN '{comp[0]}' AND '{comp[1]}'"
|
||||
if op == "LIKE":
|
||||
return f"{column} LIKE '{comp}'"
|
||||
comp_str = f"'{comp}'" if isinstance(comp, str) else str(comp)
|
||||
return f"{column} {op} {comp_str}"
|
||||
|
||||
val_str = f"'{value}'" if isinstance(value, str) else str(value)
|
||||
return f"{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))
|
||||
133
tests/unit_tests/utils/test_rison_filters.py
Normal file
133
tests/unit_tests/utils/test_rison_filters.py
Normal file
@@ -0,0 +1,133 @@
|
||||
# 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") == []
|
||||
Reference in New Issue
Block a user