diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterConfigurePane.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterConfigurePane.tsx index d02a6ba2c3165..b582b240977d4 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterConfigurePane.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FilterConfigurePane.tsx @@ -46,7 +46,7 @@ const ContentHolder = styled.div` `; const TitlesContainer = styled.div` - width: 270px; + min-width: 270px; border-right: 1px solid ${({ theme }) => theme.colors.grayscale.light2}; `; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx index d70679128e4cb..fcfde1f33a067 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx @@ -258,6 +258,19 @@ const StyledAsterisk = styled.span` } `; +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', @@ -795,6 +808,13 @@ const FiltersConfigForm = ( /> + {formFilter?.filterType === 'filter_time' && ( + + {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.`)} + + )} {hasDataset && ( {showDataset ? ( diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx index 7b0ffeb3b67d0..497ed2733685b 100644 --- a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx +++ b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx @@ -38,6 +38,7 @@ import { useTheme, isDefined, JsonValue, + NO_TIME_RANGE, } from '@superset-ui/core'; import { ControlPanelSectionConfig, @@ -55,6 +56,7 @@ import Collapse from 'src/components/Collapse'; import Tabs from 'src/components/Tabs'; import { PluginContext } from 'src/components/DynamicPlugins'; import Loading from 'src/components/Loading'; +import Modal from 'src/components/Modal'; import { usePrevious } from 'src/hooks/usePrevious'; import { getSectionsToRender } from 'src/explore/controlUtils'; @@ -66,6 +68,9 @@ import ControlRow from './ControlRow'; import Control from './Control'; import { ExploreAlert } from './ExploreAlert'; import { RunQueryButton } from './RunQueryButton'; +import { Operators } from '../constants'; + +const { confirm } = Modal; export type ControlPanelsContainerProps = { exploreState: ExplorePageState['explore']; @@ -277,6 +282,55 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { string[] | undefined >(state => state.explore.controlsTransferred); + const defaultTimeFilter = useSelector( + state => state.common?.conf?.DEFAULT_TIME_FILTER, + ); + + const { form_data, actions } = props; + const { setControlValue } = actions; + const { x_axis, adhoc_filters } = form_data; + + const previousXAxis = usePrevious(x_axis); + + useEffect(() => { + if (x_axis && x_axis !== previousXAxis) { + const noFilter = + !adhoc_filters || + !adhoc_filters.find( + filter => + filter.expressionType === 'SIMPLE' && + filter.operator === 'TEMPORAL_RANGE' && + filter.subject === x_axis, + ); + if (noFilter) { + confirm({ + title: t('The X-axis is not on the filters list'), + content: + t(`The X-axis is not on the filters list which will prevent it from being used in + time range filters in dashboards. Would you like to add it to the filters list?`), + onOk: () => { + setControlValue('adhoc_filters', [ + ...(adhoc_filters || []), + { + clause: 'WHERE', + subject: x_axis, + operator: 'TEMPORAL_RANGE', + comparator: defaultTimeFilter || NO_TIME_RANGE, + expressionType: 'SIMPLE', + }, + ]); + }, + }); + } + } + }, [ + x_axis, + adhoc_filters, + setControlValue, + defaultTimeFilter, + previousXAxis, + ]); + useEffect(() => { let shouldUpdateControls = false; const removeDatasourceWarningFromControl = ( @@ -346,15 +400,11 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { } = useMemo( () => getState( - props.form_data.viz_type, + form_data.viz_type, props.exploreState.datasource, props.datasource_type, ), - [ - props.exploreState.datasource, - props.form_data.viz_type, - props.datasource_type, - ], + [props.exploreState.datasource, form_data.viz_type, props.datasource_type], ); const resetTransferredControls = useCallback(() => { @@ -431,6 +481,32 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { ? baseDescription(exploreState, controls[name], chart) : baseDescription; + if (name === 'adhoc_filters') { + restProps.confirmDeletion = { + triggerCondition: ( + valueToBeDeleted: Record, + values: Record[], + ) => { + const isTemporalRange = (filter: Record) => + filter.operator === Operators.TEMPORAL_RANGE; + if (isTemporalRange(valueToBeDeleted)) { + const count = values.filter(isTemporalRange).length; + if (count < 2) { + return true; + } + } + return false; + }, + confirmationTitle: t( + 'Are you sure you want to remove the last temporal filter?', + ), + confirmationText: t( + `This filter is the last temporal filter. If you proceed, + this chart won't be affected by time range filters in dashboards.`, + ), + }; + } + return ( { const sectionHasHadNoErrors = useResetOnChangeRef( () => ({}), - props.form_data.viz_type, + form_data.viz_type, ); const renderControlPanelSection = ( @@ -615,7 +691,7 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { const dataTabHasHadNoErrors = useResetOnChangeRef( () => false, - props.form_data.viz_type, + form_data.viz_type, ); const dataTabTitle = useMemo(() => { @@ -661,10 +737,7 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => { ]); const controlPanelRegistry = getChartControlPanelRegistry(); - if ( - !controlPanelRegistry.has(props.form_data.viz_type) && - pluginContext.loading - ) { + if (!controlPanelRegistry.has(form_data.viz_type) && pluginContext.loading) { return ; } diff --git a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx index 8c4d6f8e831b6..90cf156c118f9 100644 --- a/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx +++ b/superset-frontend/src/explore/components/controls/DndColumnSelectControl/DndFilterSelect.tsx @@ -34,6 +34,7 @@ import { isTemporalColumn, withDndFallback, } from '@superset-ui/chart-controls'; +import Modal from 'src/components/Modal'; import { OPERATOR_ENUM_TO_OPERATOR_TYPE, Operators, @@ -58,6 +59,8 @@ import AdhocFilterControl from '../FilterControl/AdhocFilterControl'; import DndAdhocFilterOption from './DndAdhocFilterOption'; import { useDefaultTimeFilter } from '../DateFilterControl/utils'; +const { confirm } = Modal; + const EMPTY_OBJECT = {}; const DND_ACCEPTED_TYPES = [ DndItemType.Column, @@ -75,10 +78,23 @@ export interface DndFilterSelectProps savedMetrics: Metric[]; selectedMetrics: QueryFormMetric[]; datasource: Datasource; + confirmDeletion?: { + triggerCondition: ( + valueToBeDeleted: OptionValueType, + values: OptionValueType[], + ) => boolean; + confirmationTitle: string; + confirmationText: string; + }; } const DndFilterSelect = (props: DndFilterSelectProps) => { - const { datasource, onChange = () => {}, name: controlName } = props; + const { + datasource, + onChange = () => {}, + name: controlName, + confirmDeletion, + } = props; const propsValues = Array.from(props.value ?? []); const [values, setValues] = useState( @@ -189,7 +205,7 @@ const DndFilterSelect = (props: DndFilterSelectProps) => { ); }, [props.value]); - const onClickClose = useCallback( + const removeValue = useCallback( (index: number) => { const valuesCopy = [...values]; valuesCopy.splice(index, 1); @@ -199,6 +215,27 @@ const DndFilterSelect = (props: DndFilterSelectProps) => { [onChange, values], ); + const onClickClose = useCallback( + (index: number) => { + if (confirmDeletion) { + const { confirmationText, confirmationTitle, triggerCondition } = + confirmDeletion; + if (triggerCondition(values[index], values)) { + confirm({ + title: confirmationTitle, + content: confirmationText, + onOk() { + removeValue(index); + }, + }); + return; + } + } + removeValue(index); + }, + [confirmDeletion, removeValue, values], + ); + const onShiftOptions = useCallback( (dragIndex: number, hoverIndex: number) => { const newValues = [...values]; @@ -404,6 +441,7 @@ const DndFilterSelect = (props: DndFilterSelectProps) => { visible={newFilterPopoverVisible} togglePopover={togglePopover} closePopover={closePopover} + requireSave={!!droppedItem} /> ); diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.jsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.jsx index 813600a4c16d6..2114a9bd45d5c 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.jsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.jsx @@ -42,6 +42,7 @@ import { LabelsContainer, } from 'src/explore/components/controls/OptionControls'; import Icons from 'src/components/Icons'; +import Modal from 'src/components/Modal'; import AdhocFilterPopoverTrigger from 'src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger'; import AdhocFilterOption from 'src/explore/components/controls/FilterControl/AdhocFilterOption'; import AdhocFilter, { @@ -51,6 +52,8 @@ import AdhocFilter, { import adhocFilterType from 'src/explore/components/controls/FilterControl/adhocFilterType'; import columnType from 'src/explore/components/controls/FilterControl/columnType'; +const { confirm } = Modal; + const selectedMetricType = PropTypes.oneOfType([ PropTypes.string, adhocMetricType, @@ -71,6 +74,11 @@ const propTypes = { PropTypes.arrayOf(selectedMetricType), ]), isLoading: PropTypes.bool, + confirmDeletion: PropTypes.shape({ + triggerCondition: PropTypes.func, + confirmationTitle: PropTypes.string, + confirmationText: PropTypes.string, + }), }; const defaultProps = { @@ -96,6 +104,7 @@ class AdhocFilterControl extends React.Component { this.onChange = this.onChange.bind(this); this.mapOption = this.mapOption.bind(this); this.getMetricExpression = this.getMetricExpression.bind(this); + this.removeFilter = this.removeFilter.bind(this); const filters = (this.props.value || []).map(filter => isDictionaryForAdhocFilter(filter) ? new AdhocFilter(filter) : filter, @@ -173,7 +182,7 @@ class AdhocFilterControl extends React.Component { } } - onRemoveFilter(index) { + removeFilter(index) { const valuesCopy = [...this.state.values]; valuesCopy.splice(index, 1); this.setState(prevState => ({ @@ -183,6 +192,26 @@ class AdhocFilterControl extends React.Component { this.props.onChange(valuesCopy); } + onRemoveFilter(index) { + const { confirmDeletion } = this.props; + const { values } = this.state; + if (confirmDeletion) { + const { confirmationText, confirmationTitle, triggerCondition } = + confirmDeletion; + if (triggerCondition(values[index], values)) { + confirm({ + title: confirmationTitle, + content: confirmationText, + onOk() { + this.removeFilter(index); + }, + }); + return; + } + } + this.removeFilter(index); + } + onNewFilter(newFilter) { const mappedOption = this.mapOption(newFilter); if (mappedOption) { diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.jsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.jsx index fc3d5a3a0183e..98c6da8f1c004 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.jsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.jsx @@ -52,6 +52,7 @@ const propTypes = { theme: PropTypes.object, sections: PropTypes.arrayOf(PropTypes.string), operators: PropTypes.arrayOf(PropTypes.string), + requireSave: PropTypes.bool, }; const ResizeIcon = styled.i` @@ -181,12 +182,14 @@ export default class AdhocFilterEditPopover extends React.Component { partitionColumn, theme, operators, + requireSave, ...popoverProps } = this.props; const { adhocFilter } = this.state; const stateIsValid = adhocFilter.isValid(); - const hasUnsavedChanges = !adhocFilter.equals(propsAdhocFilter); + const hasUnsavedChanges = + requireSave || !adhocFilter.equals(propsAdhocFilter); return ( void; closePopover?: () => void; + requireSave?: boolean; } interface AdhocFilterPopoverTriggerState { @@ -96,6 +97,7 @@ class AdhocFilterPopoverTrigger extends React.PureComponent< sections={this.props.sections} operators={this.props.operators} onChange={this.props.onFilterEdit} + requireSave={this.props.requireSave} /> ); diff --git a/superset-frontend/src/explore/components/controls/OptionControls/index.tsx b/superset-frontend/src/explore/components/controls/OptionControls/index.tsx index 90ae73c6370aa..495fab71e5287 100644 --- a/superset-frontend/src/explore/components/controls/OptionControls/index.tsx +++ b/superset-frontend/src/explore/components/controls/OptionControls/index.tsx @@ -308,7 +308,10 @@ export const OptionControlLabel = ({ { + e.stopPropagation(); + onRemove(); + }} >