superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx
/**
* 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.
*/
/* eslint-disable react-hooks/rules-of-hooks */
import {
ColumnMeta,
InfoTooltipWithTrigger,
Metric,
} from '@superset-ui/chart-controls';
import {
AdhocFilter,
Behavior,
ChartDataResponseResult,
Column,
isFeatureEnabled,
FeatureFlag,
Filter,
GenericDataType,
getChartMetadataRegistry,
JsonResponse,
NativeFilterType,
styled,
SupersetApiError,
t,
ClientErrorObject,
getClientErrorObject,
} from '@superset-ui/core';
import { isEqual } from 'lodash';
import React, {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react';
import rison from 'rison';
import { PluginFilterSelectCustomizeProps } from 'src/filters/components/Select/types';
import { useSelector } from 'react-redux';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
import { Input, TextArea } from 'src/components/Input';
import { Select, FormInstance } from 'src/components';
import Collapse from 'src/components/Collapse';
import BasicErrorAlert from 'src/components/ErrorMessage/BasicErrorAlert';
import ErrorMessageWithStackTrace from 'src/components/ErrorMessage/ErrorMessageWithStackTrace';
import { FormItem } from 'src/components/Form';
import Icons from 'src/components/Icons';
import Loading from 'src/components/Loading';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { Radio } from 'src/components/Radio';
import Tabs from 'src/components/Tabs';
import { Tooltip } from 'src/components/Tooltip';
import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
import {
Chart,
ChartsState,
DatasourcesState,
RootState,
} from 'src/dashboard/types';
import DateFilterControl from 'src/explore/components/controls/DateFilterControl';
import AdhocFilterControl from 'src/explore/components/controls/FilterControl/AdhocFilterControl';
import { waitForAsyncData } from 'src/middleware/asyncEvent';
import { SingleValueType } from 'src/filters/components/Range/SingleValueType';
import {
getFormData,
mergeExtraFormData,
} from 'src/dashboard/components/nativeFilters/utils';
import { DatasetSelectLabel } from 'src/features/datasets/DatasetSelectLabel';
import {
ALLOW_DEPENDENCIES as TYPES_SUPPORT_DEPENDENCIES,
getFiltersConfigModalTestId,
} from '../FiltersConfigModal';
import { FilterRemoval, NativeFiltersForm } from '../types';
import { CollapsibleControl } from './CollapsibleControl';
import { ColumnSelect } from './ColumnSelect';
import DatasetSelect from './DatasetSelect';
import DefaultValue from './DefaultValue';
import FilterScope from './FilterScope/FilterScope';
import getControlItemsMap from './getControlItemsMap';
import RemovedFilter from './RemovedFilter';
import { useBackendFormUpdate, useDefaultValue } from './state';
import {
hasTemporalColumns,
mostUsedDataset,
setNativeFilterFieldValues,
useForceUpdate,
} from './utils';
import { FILTER_SUPPORTED_TYPES, INPUT_WIDTH } from './constants';
import DependencyList from './DependencyList';
const TabPane = styled(Tabs.TabPane)`
padding: ${({ theme }) => theme.gridUnit * 4}px 0px;
`;
const StyledContainer = styled.div`
${({ theme }) => `
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
padding: 0px ${theme.gridUnit * 4}px;
`}
`;
const StyledRowContainer = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
padding: 0px ${({ theme }) => theme.gridUnit * 4}px;
`;
type ControlKey = keyof PluginFilterSelectCustomizeProps;
const controlsOrder: ControlKey[] = [
'enableEmptyFilter',
'defaultToFirstItem',
'multiSelect',
'searchAllOptions',
'inverseSelection',
];
export const StyledFormItem = styled(FormItem)`
width: 49%;
margin-bottom: ${({ theme }) => theme.gridUnit * 4}px;
& .ant-form-item-label {
padding-bottom: 0;
}
& .ant-form-item-control-input {
min-height: ${({ theme }) => theme.gridUnit * 10}px;
}
`;
export const StyledRowFormItem = styled(FormItem)`
margin-bottom: 0;
padding-bottom: 0;
min-width: 50%;
& .ant-form-item-label {
padding-bottom: 0;
}
.ant-form-item-control-input-content > div > div {
height: auto;
}
& .ant-form-item-control-input {
min-height: ${({ theme }) => theme.gridUnit * 10}px;
}
`;
export const StyledRowSubFormItem = styled(FormItem)`
min-width: 50%;
& .ant-form-item-label {
padding-bottom: 0;
}
.ant-form-item {
margin-bottom: 0;
}
.ant-form-item-control-input-content > div > div {
height: auto;
}
.ant-form-item-extra {
display: none;
}
& .ant-form-item-control-input {
height: auto;
}
`;
export const StyledLabel = styled.span`
color: ${({ theme }) => theme.colors.grayscale.base};
font-size: ${({ theme }) => theme.typography.sizes.s}px;
text-transform: uppercase;
`;
const CleanFormItem = styled(FormItem)`
margin-bottom: 0;
`;
const DefaultValueContainer = styled.div`
display: flex;
flex-direction: row;
align-items: center;
`;
const RefreshIcon = styled(Icons.Refresh)`
margin-left: ${({ theme }) => theme.gridUnit * 2}px;
color: ${({ theme }) => theme.colors.primary.base};
`;
const StyledCollapse = styled(Collapse)`
border-left: 0;
border-top: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
border-radius: 0;
.ant-collapse-header {
border-bottom: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
border-top: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
margin-top: -1px;
border-radius: 0;
}
.ant-collapse-content {
border: 0;
}
.ant-collapse-content-box {
padding-top: ${({ theme }) => theme.gridUnit * 2}px;
}
&.ant-collapse > .ant-collapse-item {
border: 0;
border-radius: 0;
}
`;
const StyledTabs = styled(Tabs)`
.ant-tabs-nav {
position: sticky;
top: 0;
background: ${({ theme }) => theme.colors.grayscale.light5};
z-index: 1;
}
.ant-tabs-nav-list {
padding: 0;
}
.ant-form-item-label {
padding-bottom: 0;
}
`;
const StyledAsterisk = styled.span`
color: ${({ theme }) => theme.colors.error.base};
font-size: ${({ theme }) => theme.typography.sizes.s}px;
margin-left: ${({ theme }) => theme.gridUnit - 1}px;
&:before {
content: '*';
}
`;
const FilterTypeInfo = styled.div`
${({ theme }) => `
width: 49%;
font-size: ${theme.typography.sizes.s}px;
color: ${theme.colors.grayscale.light1};
margin:
${-theme.gridUnit * 2}px
0px
${theme.gridUnit * 4}px
${theme.gridUnit * 4}px;
`}
`;
const FilterTabs = {
configuration: {
key: 'configuration',
name: t('Settings'),
},
scoping: {
key: 'scoping',
name: t('Scoping'),
},
};
export const FilterPanels = {
configuration: {
key: 'configuration',
name: t('Filter Configuration'),
},
settings: {
key: 'settings',
name: t('Filter Settings'),
},
};
export interface FiltersConfigFormProps {
filterId: string;
filterToEdit?: Filter;
removedFilters: Record<string, FilterRemoval>;
restoreFilter: (filterId: string) => void;
form: FormInstance<NativeFiltersForm>;
getAvailableFilters: (
filterId: string,
) => { label: string; value: string; type: string | undefined }[];
handleActiveFilterPanelChange: (activeFilterPanel: string | string[]) => void;
activeFilterPanelKeys: string | string[];
isActive: boolean;
setErroredFilters: (f: (filters: string[]) => string[]) => void;
validateDependencies: () => void;
getDependencySuggestion: (filterId: string) => string;
}
const FILTERS_WITH_ADHOC_FILTERS = ['filter_select', 'filter_range'];
// TODO: Rename the filter plugins and remove this mapping
const FILTER_TYPE_NAME_MAPPING = {
[t('Select filter')]: t('Value'),
[t('Range filter')]: t('Numerical range'),
[t('Time filter')]: t('Time range'),
[t('Time column')]: t('Time column'),
[t('Time grain')]: t('Time grain'),
[t('Group By')]: t('Group by'),
};
/**
* The configuration form for a specific filter.
* Assigns field values to `filters[filterId]` in the form.
*/
const FiltersConfigForm = (
{
filterId,
filterToEdit,
removedFilters,
form,
getAvailableFilters,
activeFilterPanelKeys,
restoreFilter,
handleActiveFilterPanelChange,
setErroredFilters,
validateDependencies,
getDependencySuggestion,
isActive,
}: FiltersConfigFormProps,
ref: React.RefObject<any>,
) => {
const isRemoved = !!removedFilters[filterId];
const [error, setError] = useState<ClientErrorObject>();
const [metrics, setMetrics] = useState<Metric[]>([]);
const [activeTabKey, setActiveTabKey] = useState<string>(
FilterTabs.configuration.key,
);
const dashboardId = useSelector<RootState, number>(
state => state.dashboardInfo.id,
);
const [undoFormValues, setUndoFormValues] = useState<Record<
string,
any
> | null>(null);
const forceUpdate = useForceUpdate(isActive);
const [datasetDetails, setDatasetDetails] = useState<Record<string, any>>();
const defaultFormFilter = useMemo(() => ({}), []);
const filters = form.getFieldValue('filters');
const formValues = filters?.[filterId];
const formFilter = formValues || undoFormValues || defaultFormFilter;
const dependencies: string[] =
formFilter?.dependencies || filterToEdit?.cascadeParentIds || [];
const nativeFilterItems = getChartMetadataRegistry().items;
const nativeFilterVizTypes = Object.entries(nativeFilterItems)
// @ts-ignore
.filter(([, { value }]) => value.behaviors?.includes(Behavior.NativeFilter))
.map(([key]) => key);
const loadedDatasets = useSelector<RootState, DatasourcesState>(
({ datasources }) => datasources,
);
const charts = useSelector<RootState, ChartsState>(({ charts }) => charts);
const doLoadedDatasetsHaveTemporalColumns = useMemo(
() =>
Object.values(loadedDatasets).some(dataset =>
hasTemporalColumns(dataset),
),
[loadedDatasets],
);
const showTimeRangePicker = useMemo(() => {
const currentDataset = Object.values(loadedDatasets).find(
dataset => dataset.id === formFilter?.dataset?.value,
);
return currentDataset ? hasTemporalColumns(currentDataset) : true;
}, [formFilter?.dataset?.value, loadedDatasets]);
const hasDataset =
// @ts-ignore
!!nativeFilterItems[formFilter?.filterType]?.value?.datasourceCount;
const datasetId =
formFilter?.dataset?.value ??
filterToEdit?.targets[0]?.datasetId ??
mostUsedDataset(loadedDatasets, charts);
const { controlItems = {}, mainControlItems = {} } = formFilter
? getControlItemsMap({
datasetId,
disabled: false,
forceUpdate,
form,
filterId,
filterType: formFilter?.filterType,
filterToEdit,
formFilter,
removed: isRemoved,
})
: {};
const hasColumn = !!mainControlItems.groupby;
const nativeFilterItem = nativeFilterItems[formFilter?.filterType] ?? {};
// @ts-ignore
const enableNoResults = !!nativeFilterItem.value?.enableNoResults;
const hasMetrics = hasColumn && !!metrics.length;
const hasFilledDataset =
!hasDataset || (datasetId && (formFilter?.column || !hasColumn));
const hasAdditionalFilters = FILTERS_WITH_ADHOC_FILTERS.includes(
formFilter?.filterType,
);
const canDependOnOtherFilters = TYPES_SUPPORT_DEPENDENCIES.includes(
formFilter?.filterType,
);
const isDataDirty = formFilter?.isDataDirty ?? true;
const setNativeFilterFieldValuesWrapper = (values: object) => {
setNativeFilterFieldValues(form, filterId, values);
setError(undefined);
forceUpdate();
};
const setErrorWrapper = (error: ClientErrorObject) => {
setNativeFilterFieldValues(form, filterId, {
defaultValueQueriesData: null,
});
setError(error);
forceUpdate();
};
// Calculates the dependencies default values to be used
// to extract the available values to the filter
let dependenciesDefaultValues = {};
if (dependencies && dependencies.length > 0 && filters) {
dependencies.forEach(dependency => {
const extraFormData = filters[dependency]?.defaultDataMask?.extraFormData;
dependenciesDefaultValues = mergeExtraFormData(
dependenciesDefaultValues,
extraFormData,
);
});
}
const dependenciesText = JSON.stringify(dependenciesDefaultValues);
const refreshHandler = useCallback(
(force = false) => {
if (!hasDataset || !formFilter?.dataset?.value) {
forceUpdate();
return;
}
const formData = getFormData({
datasetId: formFilter?.dataset?.value,
dashboardId,
groupby: formFilter?.column,
...formFilter,
});
formData.extra_form_data = dependenciesDefaultValues;
setNativeFilterFieldValuesWrapper({
defaultValueQueriesData: null,
isDataDirty: false,
});
getChartDataRequest({
formData,
force,
})
.then(({ response, json }) => {
if (isFeatureEnabled(FeatureFlag.GlobalAsyncQueries)) {
// deal with getChartDataRequest transforming the response data
const result = 'result' in json ? json.result[0] : json;
if (response.status === 200) {
setNativeFilterFieldValuesWrapper({
defaultValueQueriesData: [result],
});
} else if (response.status === 202) {
waitForAsyncData(result)
.then((asyncResult: ChartDataResponseResult[]) => {
setNativeFilterFieldValuesWrapper({
defaultValueQueriesData: asyncResult,
});
})
.catch((error: Response) => {
getClientErrorObject(error).then(clientErrorObject => {
setErrorWrapper(clientErrorObject);
});
});
} else {
throw new Error(
`Received unexpected response status (${response.status}) while fetching chart data`,
);
}
} else {
setNativeFilterFieldValuesWrapper({
defaultValueQueriesData: json.result,
});
}
})
.catch((error: Response) => {
getClientErrorObject(error).then(clientErrorObject => {
setError(clientErrorObject);
});
});
},
[filterId, forceUpdate, form, formFilter, hasDataset, dependenciesText],
);
// TODO: refreshHandler changes itself because of the dependencies. Needs refactor.
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => refreshHandler(), [dependenciesText]);
const newFormData = getFormData({
datasetId,
groupby: hasColumn ? formFilter?.column : undefined,
...formFilter,
});
newFormData.extra_form_data = dependenciesDefaultValues;
const [hasDefaultValue, isRequired, defaultValueTooltip, setHasDefaultValue] =
useDefaultValue(formFilter, filterToEdit);
const showDataset =
!datasetId || datasetDetails || formFilter?.dataset?.label;
const formChanged = useCallback(() => {
form.setFields([
{
name: 'changed',
value: true,
},
]);
}, [form]);
const updateFormValues = useCallback(
(values: any) => {
setNativeFilterFieldValues(form, filterId, values);
formChanged();
},
[filterId, form, formChanged],
);
const hasPreFilter =
!!formFilter?.adhoc_filters ||
!!formFilter?.time_range ||
!!filterToEdit?.adhoc_filters?.length ||
!!filterToEdit?.time_range;
const hasEnableSingleValue =
formFilter?.controlValues?.enableSingleValue !== undefined ||
filterToEdit?.controlValues?.enableSingleValue !== undefined;
let enableSingleValue = filterToEdit?.controlValues?.enableSingleValue;
if (formFilter?.controlValues?.enableSingleMaxValue !== undefined) {
({ enableSingleValue } = formFilter.controlValues);
}
const hasSorting =
typeof formFilter?.controlValues?.sortAscending === 'boolean' ||
typeof filterToEdit?.controlValues?.sortAscending === 'boolean';
let sort = filterToEdit?.controlValues?.sortAscending;
if (typeof formFilter?.controlValues?.sortAscending === 'boolean') {
sort = formFilter.controlValues.sortAscending;
}
const showDefaultValue =
!hasDataset ||
(!isDataDirty && hasFilledDataset) ||
!mainControlItems.groupby;
const onSortChanged = (value: boolean | undefined) => {
const previous = form.getFieldValue('filters')?.[filterId].controlValues;
setNativeFilterFieldValues(form, filterId, {
controlValues: {
...previous,
sortAscending: value,
},
});
forceUpdate();
};
const onEnableSingleValueChanged = (value: SingleValueType | undefined) => {
const previous = form.getFieldValue('filters')?.[filterId].controlValues;
setNativeFilterFieldValues(form, filterId, {
controlValues: {
...previous,
enableSingleValue: value,
},
});
forceUpdate();
};
const validatePreFilter = () =>
setTimeout(
() =>
form.validateFields([
['filters', filterId, 'adhoc_filters'],
['filters', filterId, 'time_range'],
]),
0,
);
const hasTimeRange =
formFilter?.time_range && formFilter.time_range !== 'No filter';
const hasAdhoc = formFilter?.adhoc_filters?.length > 0;
const defaultToFirstItem = formFilter?.controlValues?.defaultToFirstItem;
const initialDefaultValue =
formFilter?.filterType === filterToEdit?.filterType
? filterToEdit?.defaultDataMask
: null;
const preFilterValidator = () => {
if (hasTimeRange || hasAdhoc) {
return Promise.resolve();
}
return Promise.reject(new Error(t('Pre-filter is required')));
};
const availableFilters = getAvailableFilters(filterId);
const hasAvailableFilters = availableFilters.length > 0;
const hasTimeDependency = availableFilters
.filter(filter => filter.type === 'filter_time')
.some(filter => dependencies?.includes(filter.value));
useEffect(() => {
if (datasetId) {
cachedSupersetGet({
endpoint: `/api/v1/dataset/${datasetId}?q=${rison.encode({
columns: [
'columns.column_name',
'columns.expression',
'columns.filterable',
'columns.is_dttm',
'columns.type',
'columns.verbose_name',
'database.id',
'database.database_name',
'datasource_type',
'filter_select_enabled',
'id',
'is_sqllab_view',
'main_dttm_col',
'metrics.metric_name',
'metrics.verbose_name',
'schema',
'sql',
'table_name',
],
})}`,
})
.then((response: JsonResponse) => {
setMetrics(response.json?.result?.metrics);
const dataset = response.json?.result;
// modify the response to fit structure expected by AdhocFilterControl
dataset.type = dataset.datasource_type;
dataset.filter_select = true;
setDatasetDetails(dataset);
})
.catch((response: SupersetApiError) => {
addDangerToast(response.message);
});
}
}, [datasetId]);
useImperativeHandle(ref, () => ({
changeTab(tab: 'configuration' | 'scoping') {
setActiveTabKey(tab);
},
}));
useBackendFormUpdate(form, filterId);
useEffect(() => {
if (hasDataset && hasFilledDataset && hasDefaultValue && isDataDirty) {
refreshHandler();
}
}, [
hasDataset,
hasFilledDataset,
hasDefaultValue,
isDataDirty,
refreshHandler,
showDataset,
]);
const initiallyExcludedCharts = useMemo(() => {
const excluded: number[] = [];
if (formFilter?.dataset?.value === undefined) {
return [];
}
Object.values(charts).forEach((chart: Chart) => {
const chartDatasetUid = chart.form_data?.datasource;
if (chartDatasetUid === undefined) {
return;
}
if (loadedDatasets[chartDatasetUid]?.id !== formFilter?.dataset?.value) {
excluded.push(chart.id);
}
});
return excluded;
}, [
JSON.stringify(charts),
formFilter?.dataset?.value,
JSON.stringify(loadedDatasets),
]);
useEffect(() => {
// just removed, saving current form items for eventual undo
if (isRemoved) {
setUndoFormValues(formValues);
}
}, [isRemoved]);
useEffect(() => {
// the filter was just restored after undo
if (undoFormValues && !isRemoved) {
setNativeFilterFieldValues(form, filterId, undoFormValues);
setUndoFormValues(null);
}
}, [formValues, filterId, form, isRemoved, undoFormValues]);
if (isRemoved) {
return <RemovedFilter onClick={() => restoreFilter(filterId)} />;
}
const timeColumn = (
<StyledRowFormItem
name={['filters', filterId, 'granularity_sqla']}
label={
<>
<StyledLabel>{t('Time column')}</StyledLabel>
<InfoTooltipWithTrigger
placement="top"
tooltip={
hasTimeDependency
? t('Time column to apply dependent temporal filter to')
: t('Time column to apply time range to')
}
/>
</>
}
initialValue={filterToEdit?.granularity_sqla}
>
<ColumnSelect
allowClear
form={form}
formField="granularity_sqla"
filterId={filterId}
filterValues={(column: Column) => !!column.is_dttm}
datasetId={datasetId}
onChange={column => {
// We need reset default value when when column changed
setNativeFilterFieldValues(form, filterId, {
granularity_sqla: column,
});
forceUpdate();
}}
/>
</StyledRowFormItem>
);
return (
<StyledTabs
activeKey={activeTabKey}
onChange={activeKey => setActiveTabKey(activeKey)}
centered
>
<TabPane
tab={FilterTabs.configuration.name}
key={FilterTabs.configuration.key}
forceRender
>
<StyledContainer>
<StyledFormItem
name={['filters', filterId, 'type']}
hidden
initialValue={NativeFilterType.NativeFilter}
>
<Input />
</StyledFormItem>
<StyledFormItem
name={['filters', filterId, 'name']}
label={<StyledLabel>{t('Filter name')}</StyledLabel>}
initialValue={filterToEdit?.name}
rules={[{ required: !isRemoved, message: t('Name is required') }]}
>
<Input {...getFiltersConfigModalTestId('name-input')} />
</StyledFormItem>
<StyledFormItem
name={['filters', filterId, 'filterType']}
rules={[{ required: !isRemoved, message: t('Name is required') }]}
initialValue={filterToEdit?.filterType || 'filter_select'}
label={<StyledLabel>{t('Filter Type')}</StyledLabel>}
{...getFiltersConfigModalTestId('filter-type')}
>
<Select
ariaLabel={t('Filter type')}
options={nativeFilterVizTypes.map(filterType => {
// @ts-ignore
const name = nativeFilterItems[filterType]?.value.name;
const mappedName = name
? FILTER_TYPE_NAME_MAPPING[name]
: undefined;
const isDisabled =
FILTER_SUPPORTED_TYPES[filterType]?.length === 1 &&
FILTER_SUPPORTED_TYPES[filterType]?.includes(
GenericDataType.Temporal,
) &&
!doLoadedDatasetsHaveTemporalColumns;
return {
value: filterType,
label: mappedName || name,
customLabel: isDisabled ? (
<Tooltip
title={t('Datasets do not contain a temporal column')}
>
{mappedName || name}
</Tooltip>
) : undefined,
disabled: isDisabled,
};
})}
onChange={value => {
setNativeFilterFieldValues(form, filterId, {
filterType: value,
defaultDataMask: null,
column: null,
});
forceUpdate();
}}
/>
</StyledFormItem>
</StyledContainer>
{formFilter?.filterType === 'filter_time' && (
<FilterTypeInfo>
{t(`Dashboard time range filters apply to temporal columns defined in
the filter section of each chart. Add temporal columns to the chart
filters to have this dashboard filter impact those charts.`)}
</FilterTypeInfo>
)}
{hasDataset && (
<StyledRowContainer>
{showDataset ? (
<StyledFormItem
name={['filters', filterId, 'dataset']}
label={<StyledLabel>{t('Dataset')}</StyledLabel>}
initialValue={
datasetDetails
? {
label: DatasetSelectLabel({
id: datasetDetails.id,
table_name: datasetDetails.table_name,
schema: datasetDetails.schema,
database: {
database_name:
datasetDetails.database.database_name,
},
}),
value: datasetDetails.id,
}
: undefined
}
rules={[
{ required: !isRemoved, message: t('Dataset is required') },
]}
{...getFiltersConfigModalTestId('datasource-input')}
>
<DatasetSelect
onChange={(value: { label: string; value: number }) => {
// We need to reset the column when the dataset has changed
if (value.value !== datasetId) {
setNativeFilterFieldValues(form, filterId, {
dataset: value,
defaultDataMask: null,
column: null,
});
}
forceUpdate();
}}
/>
</StyledFormItem>
) : (
<StyledFormItem label={<StyledLabel>{t('Dataset')}</StyledLabel>}>
<Loading position="inline-centered" />
</StyledFormItem>
)}
{hasDataset &&
Object.keys(mainControlItems).map(
key => mainControlItems[key].element,
)}
</StyledRowContainer>
)}
<StyledCollapse
defaultActiveKey={activeFilterPanelKeys}
onChange={key => {
handleActiveFilterPanelChange(key);
}}
expandIconPosition="right"
key={`native-filter-config-${filterId}`}
>
{formFilter?.filterType !== 'filter_time' && (
<Collapse.Panel
forceRender
header={FilterPanels.configuration.name}
key={`${filterId}-${FilterPanels.configuration.key}`}
>
{canDependOnOtherFilters && hasAvailableFilters && (
<StyledRowFormItem
name={['filters', filterId, 'dependencies']}
initialValue={dependencies}
>
<DependencyList
availableFilters={availableFilters}
dependencies={dependencies}
onDependenciesChange={dependencies => {
setNativeFilterFieldValues(form, filterId, {
dependencies,
});
forceUpdate();
validateDependencies();
formChanged();
}}
getDependencySuggestion={() =>
getDependencySuggestion(filterId)
}
>
{hasTimeDependency ? timeColumn : undefined}
</DependencyList>
</StyledRowFormItem>
)}
{hasDataset && hasAdditionalFilters && (
<CleanFormItem name={['filters', filterId, 'preFilter']}>
<CollapsibleControl
initialValue={hasPreFilter}
title={t('Pre-filter available values')}
tooltip={t(`Add filter clauses to control the filter's source query,
though only in the context of the autocomplete i.e., these conditions
do not impact how the filter is applied to the dashboard. This is useful
when you want to improve the query's performance by only scanning a subset
of the underlying data or limit the available values displayed in the filter.`)}
onChange={checked => {
formChanged();
if (checked) {
validatePreFilter();
}
}}
>
<StyledRowSubFormItem
name={['filters', filterId, 'adhoc_filters']}
css={{ width: INPUT_WIDTH }}
initialValue={filterToEdit?.adhoc_filters}
required
rules={[
{
validator: preFilterValidator,
},
]}
>
<AdhocFilterControl
columns={
datasetDetails?.columns?.filter(
(c: ColumnMeta) => c.filterable,
) || []
}
savedMetrics={datasetDetails?.metrics || []}
datasource={datasetDetails}
onChange={(filters: AdhocFilter[]) => {
setNativeFilterFieldValues(form, filterId, {
adhoc_filters: filters,
});
forceUpdate();
validatePreFilter();
}}
label={
<span>
<StyledLabel>{t('Pre-filter')}</StyledLabel>
{!hasTimeRange && <StyledAsterisk />}
</span>
}
/>
</StyledRowSubFormItem>
{showTimeRangePicker && (
<StyledRowFormItem
name={['filters', filterId, 'time_range']}
label={<StyledLabel>{t('Time range')}</StyledLabel>}
initialValue={
filterToEdit?.time_range || t('No filter')
}
required={!hasAdhoc}
rules={[
{
validator: preFilterValidator,
},
]}
>
<DateFilterControl
name="time_range"
onChange={timeRange => {
setNativeFilterFieldValues(form, filterId, {
time_range: timeRange,
});
forceUpdate();
validatePreFilter();
}}
/>
</StyledRowFormItem>
)}
{hasTimeRange && !hasTimeDependency
? timeColumn
: undefined}
</CollapsibleControl>
</CleanFormItem>
)}
{formFilter?.filterType !== 'filter_range' ? (
<CleanFormItem name={['filters', filterId, 'sortFilter']}>
<CollapsibleControl
initialValue={hasSorting}
title={t('Sort filter values')}
onChange={checked => {
onSortChanged(checked || undefined);
formChanged();
}}
>
<StyledRowFormItem
name={[
'filters',
filterId,
'controlValues',
'sortAscending',
]}
initialValue={sort}
label={<StyledLabel>{t('Sort type')}</StyledLabel>}
>
<Radio.Group
onChange={value => {
onSortChanged(value.target.value);
}}
>
<Radio value>{t('Sort ascending')}</Radio>
<Radio value={false}>{t('Sort descending')}</Radio>
</Radio.Group>
</StyledRowFormItem>
{hasMetrics && (
<StyledRowSubFormItem
name={['filters', filterId, 'sortMetric']}
initialValue={filterToEdit?.sortMetric}
label={
<>
<StyledLabel>{t('Sort Metric')}</StyledLabel>
<InfoTooltipWithTrigger
placement="top"
tooltip={t(
'If a metric is specified, sorting will be done based on the metric value',
)}
/>
</>
}
data-test="field-input"
>
<Select
allowClear
ariaLabel={t('Sort metric')}
name="sortMetric"
options={metrics.map((metric: Metric) => ({
value: metric.metric_name,
label: metric.verbose_name ?? metric.metric_name,
}))}
onChange={value => {
if (value !== undefined) {
setNativeFilterFieldValues(form, filterId, {
sortMetric: value,
});
forceUpdate();
}
}}
/>
</StyledRowSubFormItem>
)}
</CollapsibleControl>
</CleanFormItem>
) : (
<CleanFormItem name={['filters', filterId, 'rangeFilter']}>
<CollapsibleControl
initialValue={hasEnableSingleValue}
title={t('Single Value')}
onChange={checked => {
onEnableSingleValueChanged(
checked ? SingleValueType.Exact : undefined,
);
formChanged();
}}
>
<StyledRowFormItem
name={[
'filters',
filterId,
'controlValues',
'enableSingleValue',
]}
initialValue={enableSingleValue}
label={
<StyledLabel>{t('Single value type')}</StyledLabel>
}
>
<Radio.Group
onChange={value =>
onEnableSingleValueChanged(value.target.value)
}
>
<Radio value={SingleValueType.Minimum}>
{t('Minimum')}
</Radio>
<Radio value={SingleValueType.Exact}>
{t('Exact')}
</Radio>
<Radio value={SingleValueType.Maximum}>
{t('Maximum')}
</Radio>
</Radio.Group>
</StyledRowFormItem>
</CollapsibleControl>
</CleanFormItem>
)}
</Collapse.Panel>
)}
<Collapse.Panel
forceRender
header={FilterPanels.settings.name}
key={`${filterId}-${FilterPanels.settings.key}`}
>
<StyledFormItem
name={['filters', filterId, 'description']}
initialValue={filterToEdit?.description}
label={<StyledLabel>{t('Description')}</StyledLabel>}
>
<TextArea />
</StyledFormItem>
<CleanFormItem
name={['filters', filterId, 'defaultValueQueriesData']}
hidden
initialValue={null}
/>
<CleanFormItem name={['filters', filterId, 'defaultValue']}>
<CollapsibleControl
checked={hasDefaultValue}
disabled={isRequired || defaultToFirstItem}
initialValue={hasDefaultValue}
title={t('Filter has default value')}
tooltip={defaultValueTooltip}
onChange={value => {
setHasDefaultValue(value);
if (!value) {
setNativeFilterFieldValues(form, filterId, {
defaultDataMask: null,
});
}
formChanged();
}}
>
{!isRemoved && (
<StyledRowSubFormItem
name={['filters', filterId, 'defaultDataMask']}
initialValue={initialDefaultValue}
data-test="default-input"
label={<StyledLabel>{t('Default Value')}</StyledLabel>}
required={hasDefaultValue}
rules={[
{
validator: () => {
if (formFilter?.defaultDataMask?.filterState?.value) {
// requires managing the error as the DefaultValue
// component does not use an Antdesign compatible input
const formValidationFields = form.getFieldsError();
setErroredFilters(prevErroredFilters => {
if (
prevErroredFilters.length &&
!formValidationFields.find(
f => f.errors.length > 0,
)
) {
return [];
}
return prevErroredFilters;
});
return Promise.resolve();
}
setErroredFilters(prevErroredFilters => {
if (prevErroredFilters.includes(filterId)) {
return prevErroredFilters;
}
return [...prevErroredFilters, filterId];
});
return Promise.reject(
new Error(t('Default value is required')),
);
},
},
]}
>
{error || showDefaultValue ? (
<DefaultValueContainer>
{error ? (
<ErrorMessageWithStackTrace
error={error.errors?.[0]}
fallback={
<BasicErrorAlert
title={t('Cannot load filter')}
body={error.error}
level="error"
/>
}
/>
) : (
<DefaultValue
setDataMask={dataMask => {
if (
!isEqual(
initialDefaultValue?.filterState?.value,
dataMask?.filterState?.value,
)
) {
formChanged();
}
setNativeFilterFieldValues(form, filterId, {
defaultDataMask: dataMask,
});
form.validateFields([
['filters', filterId, 'defaultDataMask'],
]);
forceUpdate();
}}
hasDefaultValue={hasDefaultValue}
filterId={filterId}
hasDataset={hasDataset}
form={form}
formData={newFormData}
enableNoResults={enableNoResults}
/>
)}
{hasDataset && datasetId && (
<Tooltip title={t('Refresh the default values')}>
<RefreshIcon onClick={() => refreshHandler(true)} />
</Tooltip>
)}
</DefaultValueContainer>
) : (
t('Fill all required fields to enable "Default Value"')
)}
</StyledRowSubFormItem>
)}
</CollapsibleControl>
</CleanFormItem>
{Object.keys(controlItems)
.sort(
(a, b) =>
controlsOrder.indexOf(a as ControlKey) -
controlsOrder.indexOf(b as ControlKey),
)
.map(key => controlItems[key].element)}
</Collapse.Panel>
</StyledCollapse>
</TabPane>
<TabPane
tab={FilterTabs.scoping.name}
key={FilterTabs.scoping.key}
forceRender
>
<FilterScope
updateFormValues={updateFormValues}
pathToFormValue={['filters', filterId]}
forceUpdate={forceUpdate}
filterScope={filterToEdit?.scope}
formFilterScope={formFilter?.scope}
formScopingType={formFilter?.scoping}
initiallyExcludedCharts={initiallyExcludedCharts}
/>
</TabPane>
</StyledTabs>
);
};
export default React.memo(
forwardRef<typeof FiltersConfigForm, FiltersConfigFormProps>(
FiltersConfigForm,
),
);