feat: Add Dashboard Filter Support for Alert Reports (#32196)
Co-authored-by: Elizabeth Thompson <eschutho@gmail.com>
Co-authored-by: Hugh A Miles II <hugh@Mac.home>
diff --git a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
index 010c0cb..fb3ec30 100644
--- a/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
+++ b/superset-frontend/packages/superset-ui-core/src/utils/featureFlags.ts
@@ -26,6 +26,7 @@
AlertReports = 'ALERT_REPORTS',
AlertReportTabs = 'ALERT_REPORT_TABS',
AlertReportSlackV2 = 'ALERT_REPORT_SLACK_V2',
+ AlertReportsFilter = 'ALERT_REPORTS_FILTER',
AllowFullCsvExport = 'ALLOW_FULL_CSV_EXPORT',
AvoidColorsCollision = 'AVOID_COLORS_COLLISION',
ChartPluginsExperimental = 'CHART_PLUGINS_EXPERIMENTAL',
diff --git a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx
index dbd7eef..ecffcc4 100644
--- a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx
+++ b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx
@@ -452,7 +452,7 @@
expect(
screen.getByRole('combobox', { name: /dashboard/i }),
).toBeInTheDocument();
- expect(screen.getByText(/select tab/i)).toBeInTheDocument();
+ expect(screen.getAllByText(/select tab/i)).toHaveLength(1);
});
test('changes to content options when chart is selected', async () => {
@@ -666,3 +666,15 @@
screen.getAllByRole('combobox', { name: /delivery method/i }).length,
).toBe(1);
});
+
+test('renders dashboard filter dropdowns', async () => {
+ render(<AlertReportModal {...generateMockedProps(true, true)} />, {
+ useRedux: true,
+ });
+
+ userEvent.click(screen.getByTestId('contents-panel'));
+ const filterOptionDropdown = screen.getByRole('combobox', {
+ name: /select filter/i,
+ });
+ expect(filterOptionDropdown).toBeInTheDocument();
+});
diff --git a/superset-frontend/src/features/alerts/AlertReportModal.tsx b/superset-frontend/src/features/alerts/AlertReportModal.tsx
index 4b1d76d..cd923b6 100644
--- a/superset-frontend/src/features/alerts/AlertReportModal.tsx
+++ b/superset-frontend/src/features/alerts/AlertReportModal.tsx
@@ -39,11 +39,16 @@
} from '@superset-ui/core';
import rison from 'rison';
import { useSingleViewResource } from 'src/views/CRUD/hooks';
+import withToasts from 'src/components/MessageToasts/withToasts';
+import Owner from 'src/types/Owner';
+// import { Form as AntdForm } from 'src/components/Form';
+import { propertyComparator } from '@superset-ui/core/components/Select/utils';
import {
AsyncSelect,
Checkbox,
Collapse,
CollapseLabelInModal,
+ Form as AntdForm,
InfoTooltip,
Input,
InputNumber,
@@ -52,10 +57,8 @@
TreeSelect,
type CheckboxChangeEvent,
} from '@superset-ui/core/components';
+
import TimezoneSelector from '@superset-ui/core/components/TimezoneSelector';
-import { propertyComparator } from '@superset-ui/core/components/Select/utils';
-import withToasts from 'src/components/MessageToasts/withToasts';
-import Owner from 'src/types/Owner';
import TextAreaControl from 'src/explore/components/controls/TextAreaControl';
import { useCommonConf } from 'src/features/databases/state';
import {
@@ -75,9 +78,14 @@
TabNode,
SelectValue,
ContentType,
+ ExtraNativeFilter,
+ NativeFilterObject,
} from 'src/features/alerts/types';
+import { StatusMessage } from 'src/filters/components/common';
import { useSelector } from 'react-redux';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
+import { getChartDataRequest } from 'src/components/Chart/chartAction';
+import DateFilterControl from 'src/explore/components/controls/DateFilterControl';
import { Icons } from '@superset-ui/core/components/Icons';
import { StandardModal, ModalFormField } from 'src/components/Modal';
import NumberInput from './components/NumberInput';
@@ -92,6 +100,14 @@
VizType.PairedTTest,
];
+const StyledDivider = styled.span`
+ margin: 0 ${({ theme }) => theme.sizeUnit * 3}px;
+ color: ${({ theme }) => theme.colorSplit};
+ font-weight: ${({ theme }) => theme.fontWeightStrong};
+ font-size: ${({ theme }) => theme.fontSize}px;
+ align-content: center;
+`;
+
export interface AlertReportModalProps {
addSuccessToast: (msg: string) => void;
addDangerToast: (msg: string) => void;
@@ -274,6 +290,94 @@
textarea {
flex: 1 1 auto;
}
+
+ input[disabled] {
+ color: ${theme.colorTextDisabled};
+ }
+
+ textarea {
+ height: 300px;
+ resize: none;
+ }
+
+ input::placeholder,
+ textarea::placeholder {
+ color: ${theme.colorTextPlaceholder};
+ }
+
+ textarea,
+ input[type='text'],
+ input[type='number'] {
+ padding: ${theme.sizeUnit}px ${theme.sizeUnit * 2}px;
+ border-style: none;
+ border: 1px solid ${theme.colorBorder};
+ border-radius: ${theme.borderRadius}px;
+
+ &[name='description'] {
+ flex: 1 1 auto;
+ }
+ }
+
+ .input-label {
+ margin-left: 10px;
+ }
+
+ .filters {
+ margin: ${theme.sizeUnit * 3}px 0;
+
+ .filters-container {
+ display: flex;
+ margin: ${theme.sizeUnit * 2}px 0;
+ }
+
+ .filters-dash-container {
+ display: flex;
+ flex-direction: column;
+ max-width: 174px;
+ flex: 1;
+ margin-right: ${theme.sizeUnit * 4}px;
+
+ .control-label {
+ flex: 1;
+ margin-bottom: ${theme.sizeUnit * 2}px;
+
+ .label-with-tooltip {
+ margin-right: ${theme.sizeUnit * 2}px;
+ }
+ }
+ }
+
+ .filters-dash-select {
+ flex: 1;
+ }
+
+ .filters-dashvalue-container {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ }
+
+ .filters-delete {
+ display: flex;
+ margin-top: ${theme.sizeUnit * 8}px;
+ margin-left: ${theme.sizeUnit * 4}px;
+ }
+
+ .filters-trashcan {
+ width: ${theme.sizeUnit * 10}px;
+ display: 'flex';
+ color: ${theme.colorIcon};
+ }
+ .filters-add-container {
+ flex: '.25';
+ padding: '${theme.sizeUnit * 3} 0';
+
+ .filters-add-btn {
+ padding: ${theme.sizeUnit * 2}px;
+ color: ${theme.colorWhite};
+ }
+ }
+ }
`}
`;
@@ -334,6 +438,8 @@
ERROR_TOOLTIP_MESSAGE: t(
'Not all required fields are complete. Please provide the following:',
),
+ NATIVE_FILTER_COLUMN_ERROR_TEXT: t('Native filter column is required'),
+ NATIVE_FILTER_NO_VALUES_ERROR_TEXT: t('Native filter values has no values'),
};
const NotificationMethodAdd: FunctionComponent<NotificationMethodAddProps> = ({
@@ -400,6 +506,25 @@
const [dashboardOptions, setDashboardOptions] = useState<MetaObject[]>([]);
const [chartOptions, setChartOptions] = useState<MetaObject[]>([]);
const [tabOptions, setTabOptions] = useState<TabNode[]>([]);
+ const [nativeFilterOptions, setNativeFilterOptions] = useState<
+ {
+ value: string;
+ label: string;
+ }[]
+ >([]);
+ const [tabNativeFilters, setTabNativeFilters] = useState<object>({});
+ const [nativeFilterData, setNativeFilterData] = useState<ExtraNativeFilter[]>(
+ [
+ {
+ nativeFilterId: null,
+ filterName: '',
+ filterType: '',
+ columnLabel: '',
+ columnName: '',
+ filterValues: [],
+ },
+ ],
+ );
// Validation
const [validationStatus, setValidationStatus] = useState<ValidationObject>({
@@ -452,6 +577,7 @@
const formatOptionEnabled =
isFeatureEnabled(FeatureFlag.AlertsAttachReports) || isReport;
const tabsEnabled = isFeatureEnabled(FeatureFlag.AlertReportTabs);
+ const filtersEnabled = isFeatureEnabled(FeatureFlag.AlertReportsFilter);
const [notificationAddState, setNotificationAddState] =
useState<NotificationAddStatus>('active');
@@ -522,6 +648,116 @@
grace_period: undefined,
};
+ const fetchDashboardFilterValues = async (
+ dashboardId: number | string | undefined,
+ columnName: string,
+ datasetId: number | string,
+ vizType = 'filter_select',
+ adhocFilters = [],
+ ) => {
+ if (vizType === 'filter_time') {
+ return;
+ }
+
+ const filterValues = {
+ formData: {
+ datasource: `${datasetId}__table`,
+ groupby: [columnName],
+ metrics: ['count'],
+ row_limit: 1000,
+ showSearch: true,
+ viz_type: vizType,
+ type: 'NATIVE_FILTER',
+ dashboardId,
+ adhoc_filters: adhocFilters,
+ },
+ force: false,
+ ownState: {},
+ };
+
+ const data = await getChartDataRequest(filterValues).then(response => {
+ const rawData = response.json.result[0].data;
+ let filteredData = rawData;
+
+ if (vizType === 'filter_timecolumn') {
+ // filter for time columns types
+ filteredData = rawData.filter((item: any) => item.dtype === 2);
+ }
+
+ return filteredData.map((item: any) => {
+ if (vizType === 'filter_timegrain') {
+ return {
+ value: item.duration,
+ label: item.name,
+ };
+ }
+
+ if (vizType === 'filter_timecolumn') {
+ return {
+ value: item.column_name,
+ label: item.verbose_name || item.column_name,
+ };
+ }
+
+ return {
+ value: item[columnName],
+ label: item[columnName],
+ };
+ });
+ });
+
+ // eslint-disable-next-line consistent-return
+ return data;
+ };
+
+ const addNativeFilterOptions = (nativeFilters: NativeFilterObject[]) => {
+ nativeFilterData.map(nativeFilter => {
+ if (!nativeFilter.nativeFilterId) return;
+ const filter = nativeFilters.filter(
+ (f: any) => f.id === nativeFilter.nativeFilterId,
+ )[0];
+
+ const { datasetId } = filter.targets[0];
+ const filterName = filter.name;
+ const columnName = filter.targets[0].column?.name || filterName;
+ const dashboardId = currentAlert?.dashboard?.value;
+ const { filterType } = filter;
+
+ if (filterType === 'filter_time') {
+ return;
+ }
+
+ // eslint-disable-next-line consistent-return
+ return fetchDashboardFilterValues(
+ dashboardId,
+ columnName,
+ datasetId,
+ filterType,
+ ).then(optionFilterValues => {
+ setNativeFilterData(prev =>
+ prev.map(filter =>
+ filter.nativeFilterId === nativeFilter.nativeFilterId
+ ? {
+ ...filter,
+ filterType,
+ filterName,
+ optionFilterValues,
+ }
+ : filter,
+ ),
+ );
+ });
+ });
+ };
+
+ const filterNativeFilterOptions = () =>
+ nativeFilterOptions.filter(
+ option =>
+ !nativeFilterData.some(
+ filter => filter.nativeFilterId === option.value,
+ ),
+ );
+
const updateNotificationSetting = (
index: number,
setting: NotificationSetting,
@@ -548,7 +784,6 @@
setNotificationSettings(settings);
}
};
-
const removeNotificationSetting = (index: number) => {
const settings = notificationSettings.slice();
@@ -611,6 +846,37 @@
const shouldEnableForceScreenshot =
contentType === ContentType.Chart && !isReport;
+
+ if (currentAlert?.extra?.dashboard) {
+ // Filter out empty native filters (where both filter name and values are empty/null)
+ const validNativeFilters = nativeFilterData.filter(filter => {
+ const hasFilterName =
+ filter.filterName && filter.filterName.trim() !== '';
+ const hasFilterValues =
+ filter.filterValues && filter.filterValues.length > 0;
+ // Keep filter if it has either a name or values (or both)
+ return hasFilterName || hasFilterValues;
+ });
+
+ currentAlert.extra.dashboard.nativeFilters = validNativeFilters.map(
+ ({
+ columnName,
+ columnLabel,
+ nativeFilterId,
+ filterValues,
+ filterType,
+ filterName,
+ }) => ({
+ filterName,
+ filterType,
+ columnName,
+ columnLabel,
+ nativeFilterId,
+ filterValues,
+ }),
+ );
+ }
+
const data: any = {
...currentAlert,
type: isReport ? 'Report' : 'Alert',
@@ -771,7 +1037,11 @@
endpoint: `/api/v1/dashboard/${dashboard.value}/tabs`,
})
.then(response => {
- const { tab_tree: tabTree, all_tabs: allTabs } = response.json.result;
+ const {
+ tab_tree: tabTree,
+ all_tabs: allTabs,
+ native_filters: nativeFilters,
+ } = response.json.result;
const allTabsWithOrder = tabTree.map(
(tab: { value: string }) => tab.value,
);
@@ -786,11 +1056,32 @@
}
setTabOptions(tabTree);
+ setTabNativeFilters(nativeFilters);
+ if (isEditMode && nativeFilters.all) {
+ // update options for all filters
+ addNativeFilterOptions(nativeFilters.all);
+ // Also set the available filter options for the add button
+ setNativeFilterOptions(
+ nativeFilters.all.map((filter: any) => ({
+ value: filter.id,
+ label: filter.name,
+ })),
+ );
+ }
const anchor = currentAlert?.extra?.dashboard?.anchor;
if (anchor) {
try {
const parsedAnchor = JSON.parse(anchor);
+ if (!Array.isArray(parsedAnchor)) {
+ // only show filters scoped to anchor
+ setNativeFilterOptions(
+ nativeFilters[anchor].map((filter: any) => ({
+ value: filter.id,
+ label: filter.name,
+ })),
+ );
+ }
if (Array.isArray(parsedAnchor)) {
// Check if all elements in parsedAnchor list are in allTabs
const isValidSubset = parsedAnchor.every(tab => tab in allTabs);
@@ -805,9 +1096,16 @@
updateAnchorState(undefined);
}
}
+ } else if (nativeFilters.all) {
+ setNativeFilterOptions(
+ nativeFilters.all.map((filter: any) => ({
+ value: filter.id,
+ label: filter.name,
+ })),
+ );
}
})
- .catch(() => {
+ .catch(e => {
addDangerToast(t('There was an error retrieving dashboard tabs.'));
});
}
@@ -961,6 +1259,24 @@
}
};
+ const handleAddFilterField = () => {
+ setNativeFilterData([
+ ...nativeFilterData,
+ {
+ nativeFilterId: null,
+ columnLabel: '',
+ columnName: '',
+ filterValues: [],
+ },
+ ]);
+ };
+
+ const handleRemoveFilterField = (filterIdx: number) => {
+ const filters = nativeFilterData || [];
+ filters.splice(filterIdx, 1);
+ setNativeFilterData(filters);
+ };
+
const onCustomWidthChange = (value: number | string | null | undefined) => {
const numValue =
value === null ||
@@ -1005,8 +1321,21 @@
updateAlertState('chart', null);
if (tabsEnabled) {
setTabOptions([]);
+ setNativeFilterOptions([]);
updateAnchorState('');
}
+ if (filtersEnabled) {
+ setNativeFilterData([
+ {
+ filterName: '',
+ filterType: '',
+ nativeFilterId: null,
+ columnLabel: '',
+ columnName: '',
+ filterValues: [],
+ },
+ ]);
+ }
};
const onChartChange = (chart: SelectValue) => {
@@ -1062,6 +1391,164 @@
setForceScreenshot(e.target.checked);
};
+ const onChangeDashboardFilter = (idx: number, nativeFilterId: string) => {
+ if (
+ !nativeFilterId ||
+ nativeFilterId === 'undefined' ||
+ nativeFilterId === 'null'
+ )
+ return;
+
+ // find specific filter tied to the selected filter
+ const filters = Object.values(tabNativeFilters).flat();
+ const filter = filters.filter((f: any) => f.id === nativeFilterId)[0];
+
+ const { filterType, adhoc_filters: adhocFilters } = filter;
+ const filterAlreadyExist = nativeFilterData.some(
+ filter => filter.nativeFilterId === nativeFilterId,
+ );
+
+ if (filterAlreadyExist) {
+ addDangerToast(t('This filter already exist on the report'));
+ return;
+ }
+
+ const filterName = filter.name;
+
+ let columnName: string;
+ if (
+ filterType === 'filter_time' ||
+ filterType === 'filter_timecolumn' ||
+ filterType === 'filter_timegrain'
+ ) {
+ columnName = filter.name;
+ } else {
+ columnName = filter.targets[0].column.name;
+ }
+
+ const datasetId = filter.targets[0].datasetId || null;
+
+ const columnLabel = nativeFilterOptions.filter(
+ filter => filter.value === nativeFilterId,
+ )[0].label;
+ const dashboardId = currentAlert?.dashboard?.value;
+
+ // Get values tied to the selected filter
+ const filterValues = {
+ formData: {
+ datasource: `${datasetId}__table`,
+ groupby: [columnName],
+ metrics: ['count'],
+ row_limit: 1000,
+ showSearch: true,
+ viz_type: 'filter_select',
+ type: 'NATIVE_FILTER',
+ dashboardId,
+ adhoc_filters: adhocFilters,
+ },
+ force: false,
+ ownState: {},
+ };
+
+ // todo(hugh): put this into another function
+ if (
+ filterType === 'filter_time' ||
+ filterType === 'filter_timecolumn' ||
+ filterType === 'filter_timegrain'
+ ) {
+ fetchDashboardFilterValues(
+ dashboardId,
+ columnName,
+ datasetId,
+ filterType,
+ adhocFilters,
+ ).then(optionFilterValues => {
+ setNativeFilterData(
+ nativeFilterData.map((filter, index) =>
+ index === idx
+ ? {
+ ...filter,
+ filterName,
+ filterType,
+ nativeFilterId,
+ columnLabel,
+ columnName,
+ optionFilterValues,
+ filterValues: [], // reset filter values on filter change
+ }
+ : filter,
+ ),
+ );
+ });
+
+ setNativeFilterData(
+ nativeFilterData.map((filter, index) =>
+ index === idx
+ ? {
+ ...filter,
+ filterName,
+ filterType,
+ nativeFilterId,
+ columnLabel,
+ columnName,
+ optionFilterValues: [],
+ filterValues: [], // reset filter values on filter change
+ }
+ : filter,
+ ),
+ );
+ return;
+ }
+
+ getChartDataRequest(filterValues).then(response => {
+ const newFilterValues = response.json.result[0].data.map((item: any) => ({
+ value: item[columnName],
+ label: item[columnName],
+ }));
+
+ setNativeFilterData(
+ nativeFilterData.map((filter, index) =>
+ index === idx
+ ? {
+ ...filter,
+ filterName,
+ filterType,
+ nativeFilterId,
+ columnLabel,
+ columnName,
+ optionFilterValues: newFilterValues,
+ filterValues: [], // reset filter values on filter change
+ }
+ : filter,
+ ),
+ );
+ });
+ };
+
+ const onChangeDashboardFilterValue = (
+ idx: number,
+ filterValues:
+ | SelectValue
+ | SelectValue[]
+ | string
+ | string[]
+ | number
+ | number[],
+ ) => {
+ let values: any;
+ if (typeof filterValues === 'string') {
+ values = [filterValues];
+ } else {
+ values = filterValues;
+ }
+
+ setNativeFilterData(
+ nativeFilterData.map((filter, index) =>
+ index === idx ? { ...filter, filterValues: values } : filter,
+ ),
+ );
+ };
+
// Make sure notification settings has the required info
const checkNotificationSettings = () => {
if (!notificationSettings.length) {
@@ -1104,6 +1591,105 @@
});
};
+ const renderFilterValueSelect = (filter: ExtraNativeFilter, idx: number) => {
+ if (!filter) return null;
+ const { filterType, filterValues } = filter;
+ let mode = 'multiple';
+ if (filterType === 'filter_time') {
+ return (
+ <DateFilterControl
+ name="time_range"
+ onChange={timeRange => {
+ setNativeFilterData(
+ nativeFilterData.map((f: any) =>
+ filter.nativeFilterId === f.nativeFilterId
+ ? {
+ ...f,
+ filterValues: [timeRange],
+ }
+ : f,
+ ),
+ );
+ }}
+ value={filterValues?.[0]} // only showing first value in the array for filter_time
+ />
+ );
+ }
+ if (filterType === 'filter_range') {
+ const min = filterValues?.[0];
+ const max = filterValues?.[1];
+ return (
+ <div>
+ <div className="inline-container">
+ <InputNumber
+ value={min}
+ onChange={value => {
+ setNativeFilterData(
+ nativeFilterData.map((f: any) =>
+ f.nativeFilterId === filter.nativeFilterId
+ ? { ...f, filterValues: [value, filterValues?.[1]] }
+ : f,
+ ),
+ );
+ }}
+ />
+ <StyledDivider>-</StyledDivider>
+ <InputNumber
+ value={max}
+ onChange={value => {
+ setNativeFilterData(
+ nativeFilterData.map((f: any) =>
+ f.nativeFilterId === filter.nativeFilterId
+ ? { ...f, filterValues: [filterValues?.[0], value] }
+ : f,
+ ),
+ );
+ }}
+ />
+ </div>
+ <StatusMessage status="help">
+ {t('Enter minimum and maximum values for the range filter')}
+ </StatusMessage>
+ </div>
+ );
+ }
+
+ if (
+ filterType === 'filter_timegrain' ||
+ filterType === 'filter_timecolumn'
+ ) {
+ mode = 'single';
+ }
+
+ return (
+ <Select
+ ariaLabel={t('Select Value')}
+ placeholder={t('Select Value')}
+ disabled={!filter?.optionFilterValues}
+ value={filter?.filterValues}
+ options={filter?.optionFilterValues || []}
+ onChange={value =>
+ onChangeDashboardFilterValue(
+ idx,
+ value as
+ | string
+ | string[]
+ | number
+ | number[]
+ | SelectValue
+ | SelectValue[],
+ )
+ }
+ mode={mode as 'multiple' | 'single'}
+ onClear={() => {
+ // reset filter values on filter clear
+ onChangeDashboardFilterValue(idx, []);
+ }}
+ allowClear
+ />
+ );
+ };
+
const validateGeneralSection = () => {
const errors = [];
if (!currentAlert?.name?.length) {
@@ -1124,6 +1710,28 @@
) {
errors.push(TRANSLATIONS.CONTENT_ERROR_TEXT);
}
+
+ // validate native filter
+ nativeFilterData.forEach(filter => {
+ const columnNameCheck = !filter.columnName || filter.columnName === '';
+ const filterValuesCheck =
+ !filter.filterValues || filter.filterValues.length === 0;
+
+ if (columnNameCheck && filterValuesCheck) {
+ // if both columnName and filterValues are null or empty, skip validation
+ return;
+ }
+
+ // check if native filter columnName is null or empty
+ if (columnNameCheck) {
+ errors.push(TRANSLATIONS.NATIVE_FILTER_COLUMN_ERROR_TEXT);
+ }
+ // check if native filter values is null or empty
+ if (filterValuesCheck) {
+ errors.push(TRANSLATIONS.NATIVE_FILTER_NO_VALUES_ERROR_TEXT);
+ }
+ });
+
updateValidationStatus(Sections.Content, errors);
};
const validateAlertSection = () => {
@@ -1245,6 +1853,12 @@
useEffect(() => {
if (resource) {
+ // Add native filter settings
+ if (resource.extra?.dashboard?.nativeFilters) {
+ const filters = resource.extra.dashboard.nativeFilters;
+ setNativeFilterData(filters);
+ }
+
// Add notification settings
const settings = (resource.recipients || []).map(setting => {
const config =
@@ -1336,6 +1950,7 @@
currentAlertSafe.dashboard,
currentAlertSafe.chart,
contentType,
+ nativeFilterData,
notificationSettings,
conditionNotNull,
emailError,
@@ -1481,8 +2096,9 @@
</div>
),
},
- ...(!isReport
- ? [
+ ...(isReport
+ ? []
+ : [
{
key: 'condition',
label: (
@@ -1599,8 +2215,7 @@
</div>
),
},
- ]
- : []),
+ ]),
{
key: 'contents',
label: (
@@ -1733,6 +2348,111 @@
</>
</StyledInputContainer>
)}
+ {filtersEnabled && contentType === ContentType.Dashboard && (
+ <StyledInputContainer>
+ <AntdForm
+ className="filters"
+ name="form"
+ autoComplete="off"
+ >
+ <AntdForm.List
+ name="filters"
+ initialValue={nativeFilterData} // only show one filter field on create
+ >
+ {(fields, { add, remove }) => (
+ <div>
+ {fields.map(({ key, name: idx }) => (
+ <div className="filters-container" key={key}>
+ <div className="filters-dash-container">
+ <div className="control-label">
+ <span className="label-with-tooltip">
+ {t('Dashboard Filter')}
+ </span>
+ <InfoTooltip
+ tooltip={t(
+ 'Choose from existing dashboard filters and select a value to refine your report results.',
+ )}
+ />
+ </div>
+ <Select
+ disabled={
+ nativeFilterOptions?.length < 1 &&
+ !nativeFilterData[idx]?.filterName
+ }
+ ariaLabel={t('Select Filter')}
+ placeholder={t('Select Filter')}
+ value={nativeFilterData[idx]?.filterName}
+ options={filterNativeFilterOptions()}
+ onChange={value =>
+ onChangeDashboardFilter(
+ idx,
+ String(value),
+ )
+ }
+ onClear={() => {
+ // reset filter values on filter clear
+ nativeFilterData[idx].columnName = '';
+ nativeFilterData[idx].filterName = '';
+ nativeFilterData[idx].filterValues = [];
+ }}
+ css={css`
+ flex: 1;
+ `}
+ oneLine
+ allowClear
+ />
+ </div>
+ <div className="filters-dashvalue-container">
+ <div className="control-label">
+ {t('Value')}
+ </div>
+ {renderFilterValueSelect(
+ nativeFilterData[idx],
+ idx,
+ )}
+ </div>
+ {(idx !== 0 || isEditMode) && (
+ <div className="filters-delete">
+ <Icons.DeleteOutlined
+ iconSize="xl"
+ className="filters-trashcan"
+ onClick={() => {
+ handleRemoveFilterField(idx);
+ remove(idx);
+ }}
+ />
+ </div>
+ )}
+ </div>
+ ))}
+ <div className="filters-add-container">
+ {filterNativeFilterOptions().length > 0 && (
+ // eslint-disable-next-line jsx-a11y/anchor-is-valid
+ <a
+ className="filters-add-btn"
+ role="button"
+ tabIndex={0}
+ onClick={() => {
+ handleAddFilterField();
+ add();
+ }}
+ onKeyDown={e => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ handleAddFilterField();
+ add();
+ }
+ }}
+ >
+ + {t('Apply another dashboard filter')}
+ </a>
+ )}
+ </div>
+ </div>
+ )}
+ </AntdForm.List>
+ </AntdForm>
+ </StyledInputContainer>
+ )}
{isScreenshot && (
<StyledInputContainer
css={
diff --git a/superset-frontend/src/features/alerts/types.ts b/superset-frontend/src/features/alerts/types.ts
index 0644969..4960910 100644
--- a/superset-frontend/src/features/alerts/types.ts
+++ b/superset-frontend/src/features/alerts/types.ts
@@ -92,6 +92,17 @@
activeTabs?: Array<string>;
dataMask?: Object;
anchor?: string;
+ nativeFilters?: Array<ExtraNativeFilter>;
+};
+
+export type ExtraNativeFilter = {
+ filterName?: string;
+ filterType?: string;
+ columnName?: string;
+ columnLabel?: string;
+ filterValues?: Array<any> | [];
+ nativeFilterId?: string | null;
+ optionFilterValues?: Array<any> | [];
};
export type Extra = {
@@ -191,3 +202,36 @@
Dashboard = 'dashboard',
Chart = 'chart',
}
+
+export type NativeFilterObject = {
+ cascadeParentIds: any[];
+ chartsInScope: number[];
+ controlValues: {
+ defaultToFirstItem: boolean;
+ enableEmptyFilter: boolean;
+ inverseSelection: boolean;
+ multiSelect: boolean;
+ searchAllOptions: boolean;
+ };
+ defaultDataMask: {
+ extraFormData: Record<string, any>;
+ filterState: Record<string, any>;
+ ownState: Record<string, any>;
+ };
+ description: string;
+ filterType: string;
+ id: string;
+ name: string;
+ scope: {
+ excluded: any[];
+ rootPath: string[];
+ };
+ tabsInScope: string[];
+ targets: Array<{
+ column: {
+ name: string;
+ };
+ datasetId: number;
+ }>;
+ type: string;
+};
diff --git a/superset/commands/report/execute.py b/superset/commands/report/execute.py
index 1b19738..e51b968 100644
--- a/superset/commands/report/execute.py
+++ b/superset/commands/report/execute.py
@@ -245,16 +245,34 @@
Retrieve the URL for the dashboard tabs, or return the dashboard URL if no tabs are available.
""" # noqa: E501
force = "true" if self._report_schedule.force_screenshot else "false"
+
if (
dashboard_state := self._report_schedule.extra.get("dashboard")
) and feature_flag_manager.is_feature_enabled("ALERT_REPORT_TABS"):
+ native_filter_params = self._report_schedule.get_native_filters_params()
if anchor := dashboard_state.get("anchor"):
try:
anchor_list: list[str] = json.loads(anchor)
- return self._get_tabs_urls(anchor_list, user_friendly=user_friendly)
+ urls = self._get_tabs_urls(
+ anchor_list,
+ native_filter_params=native_filter_params,
+ user_friendly=user_friendly,
+ )
+ return urls
except json.JSONDecodeError:
logger.debug("Anchor value is not a list, Fall back to single tab")
- return [self._get_tab_url(dashboard_state)]
+
+ return [
+ self._get_tab_url(
+ {
+ "urlParams": [
+ ["native_filters", native_filter_params] # type: ignore
+ ],
+ **dashboard_state,
+ },
+ user_friendly=user_friendly,
+ )
+ ]
dashboard = self._report_schedule.dashboard
dashboard_id_or_slug = (
@@ -281,6 +299,7 @@
dashboard_id=str(self._report_schedule.dashboard.uuid),
state=dashboard_state,
).run()
+
return get_url_path(
"Superset.dashboard_permalink",
key=permalink_key,
@@ -288,7 +307,10 @@
)
def _get_tabs_urls(
- self, tab_anchors: list[str], user_friendly: bool = False
+ self,
+ tab_anchors: list[str],
+ native_filter_params: Optional[str] = None,
+ user_friendly: bool = False,
) -> list[str]:
"""
Get multple tabs urls
@@ -299,7 +321,9 @@
"anchor": tab_anchor,
"dataMask": None,
"activeTabs": None,
- "urlParams": None,
+ "urlParams": [
+ ["native_filters", native_filter_params] # type: ignore
+ ],
},
user_friendly=user_friendly,
)
@@ -338,7 +362,6 @@
]
else:
urls = self.get_dashboard_urls()
-
window_width, window_height = app.config["WEBDRIVER_WINDOW"]["dashboard"]
width = min(max_width, self._report_schedule.custom_width or window_width)
height = self._report_schedule.custom_height or window_height
@@ -500,6 +523,7 @@
error_text = None
header_data = self._get_log_data()
url = self._get_url(user_friendly=True)
+
if (
feature_flag_manager.is_feature_enabled("ALERTS_ATTACH_REPORTS")
or self._report_schedule.type == ReportScheduleType.REPORT
diff --git a/superset/config.py b/superset/config.py
index dea23c8..856b604 100644
--- a/superset/config.py
+++ b/superset/config.py
@@ -535,6 +535,7 @@
# Enables Alerts and reports new implementation
"ALERT_REPORTS": False,
"ALERT_REPORT_TABS": False,
+ "ALERT_REPORTS_FILTER": False,
"ALERT_REPORT_SLACK_V2": False,
"DASHBOARD_RBAC": False,
"ENABLE_ADVANCED_DATA_TYPES": False,
diff --git a/superset/daos/dashboard.py b/superset/daos/dashboard.py
index c9c119c..1d6f366 100644
--- a/superset/daos/dashboard.py
+++ b/superset/daos/dashboard.py
@@ -17,6 +17,7 @@
from __future__ import annotations
import logging
+from collections import defaultdict
from datetime import datetime
from typing import Any
@@ -321,6 +322,23 @@
return dash
@classmethod
+ def get_native_filter_configuration(
+ cls, id: str
+ ) -> dict[str, list[dict[str, Any]]]:
+ dashboard = cls.get_by_id_or_slug(id)
+ metadata = json.loads(dashboard.json_metadata or "{}")
+ native_filter_configuration = metadata.get("native_filter_configuration", [])
+
+ tab_filters = defaultdict(list)
+ for filter in native_filter_configuration:
+ if tabs_in_scope := filter.get("tabsInScope", []):
+ for tab_key in tabs_in_scope:
+ tab_filters[tab_key].append(filter)
+ tab_filters["all"].append(filter)
+
+ return tab_filters
+
+ @classmethod
def update_native_filters_config(
cls,
dashboard: Dashboard | None = None,
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index c811e5a..79ce952 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -479,7 +479,11 @@
""" # noqa: E501
try:
tabs = DashboardDAO.get_tabs_for_dashboard(id_or_slug)
+ native_filters = DashboardDAO.get_native_filter_configuration(id_or_slug)
+
result = self.tab_schema.dump(tabs)
+ result["native_filters"] = native_filters
+
return self.response(200, result=result)
except (TypeError, ValueError) as err:
diff --git a/superset/reports/models.py b/superset/reports/models.py
index e4cdd7c..20d3389 100644
--- a/superset/reports/models.py
+++ b/superset/reports/models.py
@@ -16,6 +16,9 @@
# under the License.
"""A collection of ORM sqlalchemy models for Superset"""
+from typing import Any, Optional
+
+import prison
from cron_descriptor import get_description
from flask_appbuilder import Model
from flask_appbuilder.models.decorators import renders
@@ -183,6 +186,117 @@
def crontab_humanized(self) -> str:
return get_description(self.crontab)
+ def get_native_filters_params(self) -> str:
+ params: dict[str, Any] = {}
+ dashboard = self.extra.get("dashboard")
+ if dashboard and dashboard.get("nativeFilters"):
+ for filter in dashboard.get("nativeFilters") or []: # type: ignore
+ params = {
+ **params,
+ **self._generate_native_filter(
+ filter["nativeFilterId"],
+ filter["filterType"],
+ filter["columnName"],
+ filter["filterValues"],
+ ),
+ }
+ # hack(hughhh): workaround for escaping prison not handling quotes right
+ rison = prison.dumps(params)
+ rison = rison.replace("'", "%27")
+ return rison
+
+ def _generate_native_filter(
+ self,
+ native_filter_id: str,
+ filter_type: str,
+ column_name: str,
+ values: list[Optional[str]],
+ ) -> dict[str, Any]:
+ if filter_type == "filter_time":
+ # For select filters, we need to use the "IN" operator
+ return {
+ native_filter_id or "": {
+ "id": native_filter_id or "",
+ "extraFormData": {"time_range": values[0]},
+ "filterState": {"value": values[0]},
+ "ownState": {},
+ }
+ }
+ elif filter_type == "filter_timegrain":
+ return {
+ native_filter_id or "": {
+ "id": native_filter_id or "",
+ "extraFormData": {
+ "time_grain_sqla": values[0], # grain
+ },
+ "filterState": {
+ # "label": "30 second", # grain_label
+ "value": values # grain
+ },
+ "ownState": {},
+ }
+ }
+
+ elif filter_type == "filter_timecolumn":
+ return {
+ native_filter_id or "": {
+ "extraFormData": {
+ "granularity_sqla": values[0] # column_name
+ },
+ "filterState": {
+ "value": values # column_name
+ },
+ }
+ }
+
+ elif filter_type == "filter_select":
+ return {
+ native_filter_id or "": {
+ "id": native_filter_id or "",
+ "extraFormData": {
+ "filters": [
+ {"col": column_name or "", "op": "IN", "val": values or []}
+ ]
+ },
+ "filterState": {
+ "label": column_name or "",
+ "validateStatus": False,
+ "value": values or [],
+ },
+ "ownState": {},
+ }
+ }
+ elif filter_type == "filter_range":
+ # For range filters, values should be [min, max] or [value] for single value
+ min_val = values[0] if len(values) > 0 else None
+ max_val = values[1] if len(values) > 1 else None
+
+ filters = []
+ if min_val is not None:
+ filters.append({"col": column_name or "", "op": ">=", "val": min_val})
+ if max_val is not None:
+ filters.append({"col": column_name or "", "op": "<=", "val": max_val})
+
+ return {
+ native_filter_id or "": {
+ "id": native_filter_id or "",
+ "extraFormData": {"filters": filters},
+ "filterState": {
+ "value": [min_val, max_val],
+ "label": f"{min_val} ≤ x ≤ {max_val}"
+ if min_val and max_val
+ else f"x ≥ {min_val}"
+ if min_val
+ else f"x ≤ {max_val}"
+ if max_val
+ else "",
+ },
+ "ownState": {},
+ }
+ }
+
+ return {}
+
class ReportRecipients(Model, AuditMixinNullable):
"""
diff --git a/superset/views/core.py b/superset/views/core.py
index d0c8d29..c495594 100755
--- a/superset/views/core.py
+++ b/superset/views/core.py
@@ -847,17 +847,24 @@
return redirect(url_for("DashboardModelView.list"))
if not value:
return json_error_response(_("permalink state not found"), status=404)
+
dashboard_id, state = value["dashboardId"], value.get("state", {})
url = url_for(
"Superset.dashboard", dashboard_id_or_slug=dashboard_id, permalink_key=key
)
if url_params := state.get("urlParams"):
- params = parse.urlencode(url_params)
- url = f"{url}&{params}"
+ for param_key, param_val in url_params:
+ if param_key == "native_filters":
+ # native_filters doesnt need to be encoded here
+ url = f"{url}&native_filters={param_val}"
+ else:
+ params = parse.urlencode([param_key, param_val]) # type: ignore
+ url = f"{url}&{params}"
if original_params := request.query_string.decode():
url = f"{url}&{original_params}"
if hash_ := state.get("anchor", state.get("hash")):
url = f"{url}#{hash_}"
+
return redirect(url)
@api
diff --git a/tests/integration_tests/dashboards/api_tests.py b/tests/integration_tests/dashboards/api_tests.py
index a655102..0ac973a 100644
--- a/tests/integration_tests/dashboards/api_tests.py
+++ b/tests/integration_tests/dashboards/api_tests.py
@@ -1177,6 +1177,7 @@
"TAB-hyTv5L7zz": "P1 - T2 - T2",
"TAB-qL7fSzr3jl": "Parent Tab 1",
},
+ "native_filters": {},
"tab_tree": [
{
"children": [
diff --git a/tests/integration_tests/reports/commands/execute_dashboard_report_tests.py b/tests/integration_tests/reports/commands/execute_dashboard_report_tests.py
index 318fd49..6c1b607 100644
--- a/tests/integration_tests/reports/commands/execute_dashboard_report_tests.py
+++ b/tests/integration_tests/reports/commands/execute_dashboard_report_tests.py
@@ -47,10 +47,12 @@
) -> None:
dashboard_screenshot_mock.get_screenshot.return_value = b"test-image"
current_app.config["ALERT_REPORTS_NOTIFICATION_DRY_RUN"] = False
-
with create_dashboard_report(
dashboard=tabbed_dashboard,
- extra={"dashboard": {"active_tabs": ["TAB-L1B", "TAB-L2BB"]}},
+ extra={
+ "activeTabs": ["TAB-L1B", "TAB-L2BB"],
+ "urlParams": [["native_filters", "()"]],
+ },
name="test report tabbed dashboard",
) as report_schedule:
dashboard: Dashboard = report_schedule.dashboard
@@ -59,7 +61,7 @@
).run()
dashboard_state = report_schedule.extra.get("dashboard", {})
permalink_key = CreateDashboardPermalinkCommand(
- str(dashboard.id), dashboard_state
+ str(dashboard.uuid), dashboard_state
).run()
expected_url = get_url_path("Superset.dashboard_permalink", key=permalink_key)
@@ -90,7 +92,10 @@
with create_dashboard_report(
dashboard=tabbed_dashboard,
- extra={"dashboard": {"active_tabs": ["TAB-L1B", "TAB-L2BB"]}},
+ extra={
+ "active_tabs": ["TAB-L1B", "TAB-L2BB"],
+ "urlParams": [["native_filters", "()"]],
+ },
name="test report tabbed dashboard",
) as report_schedule:
dashboard: Dashboard = report_schedule.dashboard
diff --git a/tests/integration_tests/reports/commands_tests.py b/tests/integration_tests/reports/commands_tests.py
index 63ff86c..ae1c855 100644
--- a/tests/integration_tests/reports/commands_tests.py
+++ b/tests/integration_tests/reports/commands_tests.py
@@ -1205,7 +1205,14 @@
report_schedule = create_report_notification(
email_target="target@email.com",
dashboard=dashboard,
- extra={"dashboard": {"anchor": "TAB-L2AB"}},
+ extra={
+ "dashboard": {
+ "anchor": "TAB-L2AB",
+ "activeTabs": None,
+ "dataMask": None,
+ "urlParams": [["native_filters", "()"]],
+ }
+ },
)
AsyncExecuteReportScheduleCommand(
TEST_ID, report_schedule.id, datetime.utcnow()
@@ -1254,7 +1261,14 @@
report_schedule = create_report_notification(
email_target="target@email.com",
dashboard=dashboard,
- extra={"dashboard": {"anchor": "TAB-L2AB"}},
+ extra={
+ "dashboard": {
+ "anchor": "TAB-L2AB",
+ "activeTabs": None,
+ "dataMask": None,
+ "urlParams": [["native_filters", "()"]],
+ }
+ },
)
AsyncExecuteReportScheduleCommand(
TEST_ID, report_schedule.id, datetime.utcnow()
diff --git a/tests/unit_tests/reports/model_test.py b/tests/unit_tests/reports/model_test.py
new file mode 100644
index 0000000..19e12e1
--- /dev/null
+++ b/tests/unit_tests/reports/model_test.py
@@ -0,0 +1,242 @@
+# 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 pytest
+
+from superset.reports.models import ReportSchedule
+
+
+def test_get_native_filters_params():
+ """
+ Test the ``get_native_filters_params`` method.
+ """
+ report_schedule = ReportSchedule()
+ report_schedule.extra = {
+ "dashboard": {
+ "nativeFilters": [
+ {
+ "nativeFilterId": "filter_id",
+ "columnName": "column_name",
+ "filterType": "filter_select",
+ "filterValues": ["value1", "value2"],
+ }
+ ]
+ }
+ }
+
+ assert report_schedule.get_native_filters_params() == (
+ "(filter_id:(extraFormData:(filters:!((col:column_name,op:IN,val:!(value1,value2)))),filterState:(label:column_name,validateStatus:!f,value:!(value1,value2)),id:filter_id,ownState:()))"
+ )
+
+
+def test_get_native_filters_params_multiple_filters():
+ """
+ Test the ``get_native_filters_params`` method with multiple native filters.
+ """
+ report_schedule = ReportSchedule()
+ report_schedule.extra = {
+ "dashboard": {
+ "nativeFilters": [
+ {
+ "nativeFilterId": "filter_id_1",
+ "filterType": "filter_select",
+ "columnName": "column_name_1",
+ "filterValues": ["value1", "value2"],
+ },
+ {
+ "nativeFilterId": "filter_id_2",
+ "filterType": "filter_select",
+ "columnName": "column_name_2",
+ "filterValues": ["value3", "value4"],
+ },
+ ]
+ }
+ }
+
+ assert report_schedule.get_native_filters_params() == (
+ "(filter_id_1:(extraFormData:(filters:!((col:column_name_1,op:IN,val:!(value1,value2)))),filterState:(label:column_name_1,validateStatus:!f,value:!(value1,value2)),id:filter_id_1,ownState:()),filter_id_2:(extraFormData:(filters:!((col:column_name_2,op:IN,val:!(value3,value4)))),filterState:(label:column_name_2,validateStatus:!f,value:!(value3,value4)),id:filter_id_2,ownState:()))"
+ )
+
+
+def test_report_generate_native_filter_no_values():
+ """
+ Test the ``_generate_native_filter`` method with no values.
+ """
+ report_schedule = ReportSchedule()
+ native_filter_id = "filter_id"
+ column_name = "column_name"
+ filter_type = "filter_select"
+ values = None
+
+ assert report_schedule._generate_native_filter(
+ native_filter_id, filter_type, column_name, values
+ ) == {
+ "filter_id": {
+ "id": "filter_id",
+ "extraFormData": {
+ "filters": [{"col": "column_name", "op": "IN", "val": []}]
+ },
+ "filterState": {
+ "label": "column_name",
+ "validateStatus": False,
+ "value": [],
+ },
+ "ownState": {},
+ }
+ }
+
+
+def test_get_native_filters_params_invalid_structure():
+ """
+ Test the ``get_native_filters_params`` method with invalid structure.
+ """
+ report_schedule = ReportSchedule()
+ report_schedule.extra = {
+ "dashboard": {
+ "nativeFilters": [
+ {
+ "nativeFilterId": "filter_id",
+ "columnName": "column_name",
+ "filterType": "filter_select",
+ # Missing "filterValues" key
+ }
+ ]
+ }
+ }
+
+ with pytest.raises(KeyError, match="'filterValues'"):
+ report_schedule.get_native_filters_params()
+
+
+# todo(hugh): how do we want to handle this case?
+# def test_report_generate_native_filter_invalid_filter_id():
+# """
+# Test the ``_generate_native_filter`` method with invalid filter id.
+# """
+# report_schedule = ReportSchedule()
+# native_filter_id = None
+# column_name = "column_name"
+# values = ["value1", "value2"]
+
+# assert report_schedule._generate_native_filter(
+# native_filter_id, column_name, values
+# ) == {}
+
+
+def test_report_generate_native_filter():
+ """
+ Test the ``_generate_native_filter`` method.
+ """
+ report_schedule = ReportSchedule()
+ native_filter_id = "filter_id"
+ filter_type = "filter_select"
+ column_name = "column_name"
+ values = ["value1", "value2"]
+
+ assert report_schedule._generate_native_filter(
+ native_filter_id, filter_type, column_name, values
+ ) == {
+ "filter_id": {
+ "extraFormData": {
+ "filters": [
+ {"col": "column_name", "op": "IN", "val": ["value1", "value2"]}
+ ]
+ },
+ "filterState": {
+ "label": "column_name",
+ "validateStatus": False,
+ "value": ["value1", "value2"],
+ },
+ "id": "filter_id",
+ "ownState": {},
+ }
+ }
+
+
+def test_get_native_filters_params_empty():
+ """
+ Test the ``get_native_filters_params`` method with empty extra.
+ """
+ report_schedule = ReportSchedule()
+ report_schedule.extra = {}
+
+ assert report_schedule.get_native_filters_params() == "()"
+
+
+def test_get_native_filters_params_no_native_filters():
+ """
+ Test the ``get_native_filters_params`` method with no native filters.
+ """
+ report_schedule = ReportSchedule()
+ report_schedule.extra = {"dashboard": {"nativeFilters": []}}
+
+ assert report_schedule.get_native_filters_params() == "()"
+
+
+def test_report_generate_native_filter_empty_values():
+ """
+ Test the ``_generate_native_filter`` method with empty values.
+ """
+ report_schedule = ReportSchedule()
+ native_filter_id = "filter_id"
+ filter_type = "filter_select"
+ column_name = "column_name"
+ values = []
+
+ assert report_schedule._generate_native_filter(
+ native_filter_id, filter_type, column_name, values
+ ) == {
+ "filter_id": {
+ "extraFormData": {
+ "filters": [{"col": "column_name", "op": "IN", "val": []}]
+ },
+ "filterState": {
+ "label": "column_name",
+ "validateStatus": False,
+ "value": [],
+ },
+ "id": "filter_id",
+ "ownState": {},
+ }
+ }
+
+
+def test_report_generate_native_filter_no_column_name():
+ """
+ Test the ``_generate_native_filter`` method with no column name.
+ """
+ report_schedule = ReportSchedule()
+ native_filter_id = "filter_id"
+ filter_type = "filter_select"
+ column_name = ""
+ values = ["value1", "value2"]
+
+ assert report_schedule._generate_native_filter(
+ native_filter_id, filter_type, column_name, values
+ ) == {
+ "filter_id": {
+ "extraFormData": {
+ "filters": [{"col": "", "op": "IN", "val": ["value1", "value2"]}]
+ },
+ "filterState": {
+ "label": "",
+ "validateStatus": False,
+ "value": ["value1", "value2"],
+ },
+ "id": "filter_id",
+ "ownState": {},
+ }
+ }