diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/nativeFilters.noInitState.test.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/nativeFilters.noInitState.test.ts index 60900ad9345..6ca48c68c60 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard/nativeFilters.noInitState.test.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/nativeFilters.noInitState.test.ts @@ -174,46 +174,139 @@ describe('Native filters', () => { validateFilterContentOnDashboard(testItems.topTenChart.filterColumnYear); }); - it('User can create a numerical range filter', () => { - visitDashboard(); - enterNativeFilterEditModal(false); - fillNativeFilterForm( - testItems.filterType.numerical, - testItems.filterNumericalColumn, - testItems.datasetForNativeFilter, - testItems.filterNumericalColumn, - ); - saveNativeFilterSettings([]); - - // Assertions - cy.get('[data-test="range-filter-from-input"]') - .should('be.visible') - .click(); - - cy.get('[data-test="range-filter-from-input"]').type('{selectall}40'); - - cy.get('[data-test="range-filter-to-input"]') - .should('be.visible') - .click(); - - cy.get('[data-test="range-filter-to-input"]').type('{selectall}50'); - cy.get(nativeFilters.applyFilter).click({ - force: true, + describe.only('Numerical Range Filter - Display Modes', () => { + beforeEach(() => { + visitDashboard(); }); - // Assert that the URL contains 'native_filters' - cy.url().then(u => { - const ur = new URL(u); - expect(ur.search).to.include('native_filters'); + const expandFilterConfiguration = () => { + cy.get('.ant-collapse-header') + .contains('Filter Configuration') + .should('be.visible') + .then($header => { + cy.wrap($header) + .closest('.ant-collapse-item') + .invoke('hasClass', 'ant-collapse-item-active') + .then(isExpanded => { + if (!isExpanded) cy.wrap($header).click(); + }); + }); + cy.get('.ant-collapse-content-box').should('be.visible'); + }; + + const selectRangeTypeOption = (label: string) => { + cy.contains('Range Type') + .should('be.visible') + .closest('.ant-form-item') + .within(() => { + cy.get('.ant-select-selector').click(); + }); + + cy.get('.ant-select-dropdown:visible') + .contains('.ant-select-item-option', label) + .click(); + }; + + const applyAndAssertInputs = (from: string, to: string) => { + // Set 'from' input + cy.get('[data-test="range-filter-from-input"]').clear(); + cy.get('[data-test="range-filter-from-input"]').type(from); + cy.get('[data-test="range-filter-from-input"]').blur(); + + // Set 'to' input + cy.get('[data-test="range-filter-to-input"]').clear(); + cy.get('[data-test="range-filter-to-input"]').type(to); + cy.get('[data-test="range-filter-to-input"]').blur(); + + // Assert values without chaining after .invoke() cy.get('[data-test="range-filter-from-input"]') .invoke('val') - .should('equal', '40'); + .then(val => { + expect(val).to.equal(from); + }); - // Assert that the "To" input has the correct value cy.get('[data-test="range-filter-to-input"]') .invoke('val') - .should('equal', '50'); + .then(val => { + expect(val).to.equal(to); + }); + }; + + it('User can create a numerical range filter with "Range Inputs" display mode', () => { + enterNativeFilterEditModal(false); + + fillNativeFilterForm( + testItems.filterType.numerical, + testItems.filterNumericalColumn, + testItems.datasetForNativeFilter, + testItems.filterNumericalColumn, + ); + + expandFilterConfiguration(); + selectRangeTypeOption('Range Inputs'); + + saveNativeFilterSettings([]); + cy.wait(500); // allow filter to mount + + applyAndAssertInputs('40', '70'); + }); + + it('User can change the display mode to "Slider"', () => { + enterNativeFilterEditModal(false); + + fillNativeFilterForm( + testItems.filterType.numerical, + testItems.filterNumericalColumn, + testItems.datasetForNativeFilter, + testItems.filterNumericalColumn, + ); + + expandFilterConfiguration(); + + cy.contains('Range Type') + .should('be.visible') + .closest('.ant-form-item') + .within(() => { + cy.get('.ant-select-selector').click({ force: true }); + }); + + cy.get('.ant-select-dropdown:visible .ant-select-item-option') + .contains(/^Slider$/) + .click({ force: true }); + + cy.get('.ant-select-selector').should('contain.text', 'Slider'); + + saveNativeFilterSettings([]); + + cy.get('.ant-slider', { timeout: 10000 }).should('be.visible'); + + cy.get('[data-test="range-filter-from-input"]', { + timeout: 5000, + }).should('not.exist'); + cy.get('[data-test="range-filter-to-input"]', { timeout: 5000 }).should( + 'not.exist', + ); + }); + + it('User can change the display mode to "Slider and range input"', () => { + enterNativeFilterEditModal(false); + + // Re-create filter + fillNativeFilterForm( + testItems.filterType.numerical, + testItems.filterNumericalColumn, + testItems.datasetForNativeFilter, + testItems.filterNumericalColumn, + ); + + expandFilterConfiguration(); + selectRangeTypeOption('Slider and range input'); + + saveNativeFilterSettings([]); + cy.wait(500); + + applyAndAssertInputs('40', '70'); }); }); diff --git a/superset-frontend/cypress-base/cypress/e2e/dashboard/utils.ts b/superset-frontend/cypress-base/cypress/e2e/dashboard/utils.ts index 83326f965af..8ed39621ba3 100644 --- a/superset-frontend/cypress-base/cypress/e2e/dashboard/utils.ts +++ b/superset-frontend/cypress-base/cypress/e2e/dashboard/utils.ts @@ -363,9 +363,26 @@ export function saveNativeFilterSettings(charts: ChartSpec[]) { cy.get(nativeFilters.modal.footer) .contains('Save') .should('be.visible') - .click(); + .click({ force: true }); + + // Wait for modal to either close or remain open + cy.get('body').should($body => { + const modalExists = $body.find(nativeFilters.modal.container).length > 0; + if (modalExists) { + cy.get(nativeFilters.modal.footer) + .contains('Save') + .should('be.visible') + .click({ force: true }); + } + }); + + // Ensure modal is closed cy.get(nativeFilters.modal.container).should('not.exist'); - charts.forEach(waitForChartLoad); + + // Wait for all charts to load + charts.forEach(chart => { + waitForChartLoad(chart); + }); } /** ************************************************************************ diff --git a/superset-frontend/packages/superset-ui-core/src/components/DropdownContainer/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/DropdownContainer/index.tsx index b9835e3e132..7fab3e498cc 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/DropdownContainer/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/DropdownContainer/index.tsx @@ -17,8 +17,10 @@ * under the License. */ import { + CSSProperties, cloneElement, forwardRef, + ReactElement, RefObject, useEffect, useImperativeHandle, @@ -26,17 +28,85 @@ import { useMemo, useState, useRef, + ReactNode, + useCallback, } from 'react'; import { Global } from '@emotion/react'; import { css, t, useTheme, usePrevious } from '@superset-ui/core'; import { useResizeDetector } from 'react-resize-detector'; import { Badge, Icons, Button, Tooltip, Popover } from '..'; -import type { - DropdownContainerProps, - DropdownItem, - DropdownRef, -} from './types'; +/** + * Container item. + */ +export interface DropdownItem { + /** + * String that uniquely identifies the item. + */ + id: string; + /** + * The element to be rendered. + */ + element: ReactElement; +} + +/** + * Horizontal container that displays overflowed items in a dropdown. + * It shows an indicator of how many items are currently overflowing. + */ +export interface DropdownContainerProps { + /** + * Array of items. The id property is used to uniquely identify + * the elements when rendering or dealing with event handlers. + */ + items: DropdownItem[]; + /** + * Event handler called every time an element moves between + * main container and dropdown. + */ + onOverflowingStateChange?: (overflowingState: { + notOverflowed: string[]; + overflowed: string[]; + }) => void; + /** + * Option to customize the content of the dropdown. + */ + dropdownContent?: (overflowedItems: DropdownItem[]) => ReactElement; + /** + * Dropdown ref. + */ + dropdownRef?: RefObject; + /** + * Dropdown additional style properties. + */ + dropdownStyle?: CSSProperties; + /** + * Displayed count in the dropdown trigger. + */ + dropdownTriggerCount?: number; + /** + * Icon of the dropdown trigger. + */ + dropdownTriggerIcon?: ReactElement; + /** + * Text of the dropdown trigger. + */ + dropdownTriggerText?: string; + /** + * Text of the dropdown trigger tooltip + */ + dropdownTriggerTooltip?: ReactNode | null; + /** + * Main container additional style properties. + */ + style?: CSSProperties; + /** + * Force render popover content before it's first opened + */ + forceRender?: boolean; +} + +export type DropdownRef = HTMLDivElement & { open: () => void }; const MAX_HEIGHT = 500; @@ -73,6 +143,37 @@ export const DropdownContainer = forwardRef( const [showOverflow, setShowOverflow] = useState(false); + // callback to update item widths so that the useLayoutEffect runs whenever + // width of any of the child changes + const recalculateItemWidths = useCallback(() => { + const mainItemsContainerNode = current?.children.item(0); + if (mainItemsContainerNode) { + const visibleChildrenElements = Array.from( + mainItemsContainerNode.children, + ); + setItemsWidth(prevGlobalWidths => { + if (prevGlobalWidths.length !== items.length) { + return prevGlobalWidths; + } + + const newGlobalWidths = [...prevGlobalWidths]; + let changed = false; + visibleChildrenElements.forEach((child, indexInVisible) => { + const originalItemIndex = indexInVisible; + if (originalItemIndex < newGlobalWidths.length) { + const newWidth = child.getBoundingClientRect().width; + if (newGlobalWidths[originalItemIndex] !== newWidth) { + newGlobalWidths[originalItemIndex] = newWidth; + changed = true; + } + } + }); + + return changed ? newGlobalWidths : prevGlobalWidths; + }); + } + }, [current?.children, items.length]); + const reduceItems = (items: DropdownItem[]): [DropdownItem[], string[]] => items.reduce( ([items, ids], item) => { @@ -122,24 +223,7 @@ export const DropdownContainer = forwardRef( childrenArray.map(child => resizeObserver.unobserve(child)); resizeObserver.disconnect(); }; - }, [items.length]); - - // callback to update item widths so that the useLayoutEffect runs whenever - // width of any of the child changes - const recalculateItemWidths = () => { - const container = current?.children.item(0); - if (container) { - const { children } = container; - const childrenArray = Array.from(children); - - const currentWidths = childrenArray.map( - child => child.getBoundingClientRect().width, - ); - - // Update state with new widths - setItemsWidth(currentWidths); - } - }; + }, [items.length, current, recalculateItemWidths]); useLayoutEffect(() => { if (popoverVisible) { @@ -206,6 +290,7 @@ export const DropdownContainer = forwardRef( overflowedItems.length, previousWidth, width, + popoverVisible, ]); useEffect(() => { @@ -376,5 +461,3 @@ export const DropdownContainer = forwardRef( ); }, ); - -export type { DropdownItem, DropdownRef }; diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx index 97b4d318896..99a94c88d28 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterBar/FilterControls/FilterControl.tsx @@ -162,6 +162,8 @@ const HorizontalFormItem = styled(StyledFormItem)<{ } .ant-form-item-label { + display: flex; + align-items: center; overflow: visible; padding-bottom: 0; margin-right: ${({ theme }) => theme.sizeUnit * 2}px; @@ -177,7 +179,7 @@ const HorizontalFormItem = styled(StyledFormItem)<{ } .ant-form-item-control { - width: ${({ inverseSelection }) => (inverseSelection ? 252 : 164)}px; + min-width: ${({ inverseSelection }) => (inverseSelection ? 252 : 164)}px; } .select-container { 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 20e22c4b838..8224f6aced6 100644 --- a/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx +++ b/superset-frontend/src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigForm/FiltersConfigForm.tsx @@ -82,6 +82,7 @@ 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 { RangeDisplayMode } from 'src/filters/components/Range/types'; import { getFormData, mergeExtraFormData, @@ -736,6 +737,12 @@ const FiltersConfigForm = ( /> ); + const isValidFilterValue = (value: any, isRangeFilter: boolean) => { + if (isRangeFilter) { + return Array.isArray(value) && (value[0] !== null || value[1] !== null); + } + return !!value; + }; return ( ) : ( - - { - onEnableSingleValueChanged( - checked - ? SingleValueType.Exact - : undefined, - ); - formChanged(); - }} + <> + + { + onEnableSingleValueChanged( + checked + ? SingleValueType.Exact + : undefined, + ); + formChanged(); + }} + > + + {t('Single value type')} + + } + > + { + onEnableSingleValueChanged( + value.target.value, + ); + formChanged(); + }} + options={[ + { + label: t('Minimum'), + value: SingleValueType.Minimum, + }, + { + label: t('Exact'), + value: SingleValueType.Exact, + }, + { + label: t('Maximum'), + value: SingleValueType.Maximum, + }, + ]} + /> + + + + + - {t('Single value type')} + {t('Range Type')} } > - { - onEnableSingleValueChanged( - value.target.value, - ); - formChanged(); - }} +