This is an automated email from the ASF dual-hosted git repository.
kasiazjc pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 4d95a8d0348 feat(listview): compact filter pills with popover for CRUD
views (#40169)
4d95a8d0348 is described below
commit 4d95a8d0348ff6cbbe3d1e5ee6fa61ddef4e5ab2
Author: Kasia <[email protected]>
AuthorDate: Sat May 30 10:30:40 2026 +0200
feat(listview): compact filter pills with popover for CRUD views (#40169)
---
scripts/oxlint.sh | 2 +-
superset-frontend/spec/helpers/testing-library.tsx | 30 ++
.../src/components/Chart/chartAction.ts | 7 +-
.../components/ListView/CardSortSelect.test.tsx | 102 ++++++
.../src/components/ListView/CardSortSelect.tsx | 76 ++--
.../ListView/Filters/CompactFilterTrigger.test.tsx | 145 ++++++++
.../ListView/Filters/CompactFilterTrigger.tsx | 198 +++++++++++
.../ListView/Filters/CompactSelectPanel.test.tsx | 339 ++++++++++++++++++
.../ListView/Filters/CompactSelectPanel.tsx | 318 +++++++++++++++++
.../src/components/ListView/Filters/DateRange.tsx | 112 ------
.../ListView/Filters/FilterPopoverContent.test.tsx | 80 +++++
.../ListView/Filters/FilterPopoverContent.tsx | 74 ++++
.../components/ListView/Filters/Select.test.tsx | 267 --------------
.../src/components/ListView/Filters/Select.tsx | 154 ---------
.../components/ListView/Filters/TimeRange.test.tsx | 251 ++++++++++++++
.../src/components/ListView/Filters/TimeRange.tsx | 291 ++++++++++++++++
.../src/components/ListView/Filters/index.test.tsx | 335 +++++++++++++++++-
.../src/components/ListView/Filters/index.tsx | 385 ++++++++++++++++-----
.../src/components/ListView/ListView.test.tsx | 21 +-
.../src/components/ListView/ListView.tsx | 81 ++++-
.../src/pages/ChartList/ChartList.test.tsx | 9 +-
.../DashboardList/DashboardList.cardview.test.tsx | 16 +-
.../src/pages/DashboardList/DashboardList.test.tsx | 8 +-
.../DatasetList/DatasetList.integration.test.tsx | 6 +-
.../DatasetList/DatasetList.listview.test.tsx | 23 +-
.../src/pages/DatasetList/DatasetList.test.tsx | 7 +-
.../src/pages/GroupsList/GroupsList.test.tsx | 16 +-
.../src/pages/RolesList/RolesList.test.tsx | 7 +-
.../RowLevelSecurityList.test.tsx | 9 +-
.../src/pages/UsersList/UsersList.test.tsx | 20 +-
30 files changed, 2649 insertions(+), 740 deletions(-)
diff --git a/scripts/oxlint.sh b/scripts/oxlint.sh
index 95f48afabb6..9baf026ddf2 100755
--- a/scripts/oxlint.sh
+++ b/scripts/oxlint.sh
@@ -55,7 +55,7 @@ if [ ${#js_ts_files[@]} -gt 0 ]; then
echo "$output" >&2
exit 1
}
- [ -n "$output" ] && echo "$output"
+ if [ -n "$output" ]; then echo "$output"; fi
else
echo "No JavaScript/TypeScript files to lint"
fi
diff --git a/superset-frontend/spec/helpers/testing-library.tsx
b/superset-frontend/spec/helpers/testing-library.tsx
index c55f2991c0e..be4211bd72a 100644
--- a/superset-frontend/spec/helpers/testing-library.tsx
+++ b/superset-frontend/spec/helpers/testing-library.tsx
@@ -152,3 +152,33 @@ export async function selectOption(option: string,
selectName?: string) {
);
await userEvent.click(item);
}
+
+/**
+ * Select an option from a compact pill filter (new UI that replaced
comboboxes).
+ * Clicks the pill button matching the label, then clicks the option in the
panel.
+ */
+export async function selectPillOption(option: string, pillLabel?: string) {
+ let pill: HTMLElement;
+ if (pillLabel) {
+ // Find the pill whose text content includes the label
+ pill = await waitFor(() => {
+ const pills = screen.getAllByTestId('compact-filter-pill');
+ const match = pills.find(p => p.textContent?.includes(pillLabel));
+ if (!match)
+ throw new Error(`Could not find pill with label "${pillLabel}"`);
+ return match;
+ });
+ } else {
+ pill = await screen.findByTestId('compact-filter-pill');
+ }
+ await userEvent.click(pill);
+ // Wait for the option list to appear and click the item
+ const item = await waitFor(() => {
+ const listbox = document.querySelector('[role="listbox"]');
+ if (!listbox) throw new Error('No listbox found');
+ const opt = within(listbox as HTMLElement).getByText(option);
+ if (!opt) throw new Error(`Option "${option}" not found`);
+ return opt;
+ });
+ await userEvent.click(item);
+}
diff --git a/superset-frontend/src/components/Chart/chartAction.ts
b/superset-frontend/src/components/Chart/chartAction.ts
index c44c3ccd341..53caea51e88 100644
--- a/superset-frontend/src/components/Chart/chartAction.ts
+++ b/superset-frontend/src/components/Chart/chartAction.ts
@@ -817,8 +817,11 @@ export function exploreJSON(
),
);
(queriesResponse as QueryData[]).forEach(response => {
- if (response.warning) {
- dispatch(addWarningToast(response.warning, { noDuplicate: true }));
+ const { warning } = response as QueryData & {
+ warning?: string | null;
+ };
+ if (warning) {
+ dispatch(addWarningToast(warning, { noDuplicate: true }));
}
});
return dispatch(
diff --git a/superset-frontend/src/components/ListView/CardSortSelect.test.tsx
b/superset-frontend/src/components/ListView/CardSortSelect.test.tsx
new file mode 100644
index 00000000000..6e00683999e
--- /dev/null
+++ b/superset-frontend/src/components/ListView/CardSortSelect.test.tsx
@@ -0,0 +1,102 @@
+/**
+ * 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 { render, screen } from 'spec/helpers/testing-library';
+import userEvent from '@testing-library/user-event';
+import { CardSortSelect } from './CardSortSelect';
+
+const options = [
+ { desc: false, id: 'title', label: 'Alphabetical', value: 'alphabetical' },
+ {
+ desc: true,
+ id: 'changed_on',
+ label: 'Recently modified',
+ value: 'recently_modified',
+ },
+ {
+ desc: false,
+ id: 'changed_on',
+ label: 'Least recently modified',
+ value: 'least_recently_modified',
+ },
+];
+
+test('pill always shows "Sort" label with no value suffix and no clear
button', () => {
+ render(
+ <CardSortSelect
+ options={options}
+ onChange={jest.fn()}
+ initialSort={[{ id: 'title', desc: false }]}
+ />,
+ );
+ expect(screen.getByText('Sort')).toBeInTheDocument();
+ expect(screen.queryByText(/sort.*alphabetical/i)).not.toBeInTheDocument();
+ expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
+ expect(screen.getByTestId('compact-filter-pill')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
+});
+
+test('no clear button even when a non-default sort is active', () => {
+ render(
+ <CardSortSelect
+ options={options}
+ onChange={jest.fn()}
+ initialSort={[{ id: 'changed_on', desc: true }]}
+ />,
+ );
+ expect(screen.getByText('Sort')).toBeInTheDocument();
+ expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
+});
+
+test('clicking a sort option from the panel calls onChange with the correct id
and desc', async () => {
+ const onChange = jest.fn();
+ render(
+ <CardSortSelect
+ options={options}
+ onChange={onChange}
+ initialSort={[{ id: 'title', desc: false }]}
+ />,
+ );
+
+ await userEvent.click(screen.getByTestId('compact-filter-pill'));
+ expect(screen.getByText('Recently modified')).toBeInTheDocument();
+
+ await userEvent.click(screen.getByText('Recently modified'));
+
+ expect(onChange).toHaveBeenCalledWith([{ id: 'changed_on', desc: true }]);
+ // Pill label stays "Sort" — value is in tooltip, not the label
+ expect(screen.getByText('Sort')).toBeInTheDocument();
+});
+
+test('selecting a different option from the panel calls onChange with correct
args', async () => {
+ const onChange = jest.fn();
+ render(
+ <CardSortSelect
+ options={options}
+ onChange={onChange}
+ initialSort={[{ id: 'title', desc: false }]}
+ />,
+ );
+
+ await userEvent.click(screen.getByTestId('compact-filter-pill'));
+ await userEvent.click(screen.getByText('Least recently modified'));
+
+ expect(onChange).toHaveBeenCalledWith([{ id: 'changed_on', desc: false }]);
+});
diff --git a/superset-frontend/src/components/ListView/CardSortSelect.tsx
b/superset-frontend/src/components/ListView/CardSortSelect.tsx
index 57617fc2f25..96c71f81103 100644
--- a/superset-frontend/src/components/ListView/CardSortSelect.tsx
+++ b/superset-frontend/src/components/ListView/CardSortSelect.tsx
@@ -16,20 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { useState, useMemo } from 'react';
+import { useRef, useState } from 'react';
import { t } from '@apache-superset/core/translation';
-import { styled } from '@apache-superset/core/theme';
-import { FormLabel, Select } from '@superset-ui/core/components';
-import { SELECT_WIDTH } from './utils';
+import type { SelectOption } from './types';
import { CardSortSelectOption, SortColumn } from './types';
-
-const SortContainer = styled.div`
- display: inline-flex;
- font-size: ${({ theme }) => theme.fontSizeSM}px;
- align-items: center;
- text-align: left;
- width: ${SELECT_WIDTH}px;
-`;
+import CompactFilterTrigger from './Filters/CompactFilterTrigger';
+import CompactSelectPanel from './Filters/CompactSelectPanel';
+import type { FilterHandler } from './Filters/types';
interface CardViewSelectSortProps {
onChange: (value: SortColumn[]) => void;
@@ -42,6 +35,8 @@ export const CardSortSelect = ({
onChange,
options,
}: CardViewSelectSortProps) => {
+ const panelRef = useRef<FilterHandler>(null);
+
const defaultSort =
(initialSort &&
options.find(
@@ -50,44 +45,41 @@ export const CardSortSelect = ({
)) ||
options[0];
- const [value, setValue] = useState({
+ const [currentValue, setCurrentValue] = useState<SelectOption>({
label: defaultSort.label,
value: defaultSort.value,
});
- const formattedOptions = useMemo(
- () => options.map(option => ({ label: option.label, value: option.value
})),
- [options],
- );
+ const selectOptions = options.map(o => ({ label: o.label, value: o.value }));
- const handleOnChange = (selected: { label: string; value: string }) => {
- setValue(selected);
- const originalOption = options.find(
- ({ value }) => value === selected.value,
- );
- if (originalOption) {
- const sortBy = [
- {
- id: originalOption.id,
- desc: originalOption.desc,
- },
- ];
- onChange(sortBy);
+ const handleSelect = (option: SelectOption | undefined) => {
+ if (!option) return;
+ const original = options.find(o => o.value === option.value);
+ if (original) {
+ setCurrentValue({ label: original.label, value: original.value });
+ onChange([{ id: original.id, desc: original.desc }]);
}
};
return (
- <SortContainer>
- <Select
- ariaLabel={t('Sort')}
- header={<FormLabel>{t('Sort')}</FormLabel>}
- labelInValue
- onChange={handleOnChange}
- options={formattedOptions}
- showSearch
- value={value}
- data-test="card-sort-select"
- />
- </SortContainer>
+ <span data-test="card-sort-select">
+ <CompactFilterTrigger
+ label={t('Sort')}
+ hasValue={false}
+ onClear={() => {}}
+ tooltipTitle={String(currentValue.label)}
+ >
+ {({ isOpen, onClose }) => (
+ <CompactSelectPanel
+ ref={panelRef}
+ selects={selectOptions}
+ value={currentValue}
+ onSelect={handleSelect}
+ isOpen={isOpen}
+ onClose={onClose}
+ />
+ )}
+ </CompactFilterTrigger>
+ </span>
);
};
diff --git
a/superset-frontend/src/components/ListView/Filters/CompactFilterTrigger.test.tsx
b/superset-frontend/src/components/ListView/Filters/CompactFilterTrigger.test.tsx
new file mode 100644
index 00000000000..4267f51d795
--- /dev/null
+++
b/superset-frontend/src/components/ListView/Filters/CompactFilterTrigger.test.tsx
@@ -0,0 +1,145 @@
+/**
+ * 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 { render, screen } from 'spec/helpers/testing-library';
+import userEvent from '@testing-library/user-event';
+import CompactFilterTrigger from './CompactFilterTrigger';
+
+// Base props without children — pass children as JSX to avoid
no-children-prop lint rule.
+const baseProps = {
+ label: 'Owner',
+ hasValue: false,
+ onClear: jest.fn(),
+};
+
+const defaultChildren = jest.fn(() => (
+ <div data-testid="filter-content">Filter content</div>
+));
+
+function renderTrigger(
+ props: Partial<
+ typeof baseProps & {
+ hasValue: boolean;
+ tooltipTitle?: string;
+ popupType?: 'listbox' | 'dialog';
+ }
+ > = {},
+ children = defaultChildren,
+) {
+ return render(
+ <CompactFilterTrigger {...baseProps} {...props}>
+ {children}
+ </CompactFilterTrigger>,
+ );
+}
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+test('renders the label', () => {
+ renderTrigger();
+ expect(screen.getByText('Owner')).toBeInTheDocument();
+});
+
+test('renders as inactive pill with down chevron when hasValue is false', ()
=> {
+ renderTrigger();
+ const pill = screen.getByTestId('compact-filter-pill');
+ expect(pill).toBeInTheDocument();
+ // No clear button when inactive
+ expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
+});
+
+test('renders active state with clear icon when hasValue is true', () => {
+ renderTrigger({ hasValue: true });
+ expect(screen.getByTestId('compact-filter-clear')).toBeInTheDocument();
+});
+
+test('clear icon has descriptive aria-label matching the filter name', () => {
+ renderTrigger({ hasValue: true });
+ const clearIcon = screen.getByTestId('compact-filter-clear');
+ expect(clearIcon).toHaveAttribute('aria-label', 'Clear Owner filter');
+});
+
+test('clear icon is rendered inside the pill button', () => {
+ renderTrigger({ hasValue: true });
+ const pill = screen.getByTestId('compact-filter-pill');
+ const clearIcon = screen.getByTestId('compact-filter-clear');
+ expect(pill).toContainElement(clearIcon);
+});
+
+test('toggles aria-expanded when pill is clicked', async () => {
+ renderTrigger();
+ const pill = screen.getByTestId('compact-filter-pill');
+ expect(pill).toHaveAttribute('aria-expanded', 'false');
+ await userEvent.click(pill);
+ expect(pill).toHaveAttribute('aria-expanded', 'true');
+});
+
+test('calls onClear when clear icon is clicked', async () => {
+ const onClear = jest.fn();
+ renderTrigger({ hasValue: true, onClear } as any);
+ const clearIcon = screen.getByTestId('compact-filter-clear');
+ await userEvent.click(clearIcon);
+ expect(onClear).toHaveBeenCalledTimes(1);
+});
+
+test('does not render tooltip wrapper when tooltipTitle is absent', () => {
+ const { container } = renderTrigger();
+ expect(container.querySelector('.ant-tooltip')).not.toBeInTheDocument();
+});
+
+test('shows active state indicators when hasValue and tooltipTitle are set',
() => {
+ renderTrigger({ hasValue: true, tooltipTitle: 'Some Owner' });
+ expect(screen.getByTestId('compact-filter-clear')).toBeInTheDocument();
+ expect(screen.getByTestId('compact-filter-pill')).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
+});
+
+test('calls children render prop with isOpen and onClose', async () => {
+ const children = jest.fn(() => <div data-testid="panel-content">panel</div>);
+ renderTrigger({}, children);
+ const pill = screen.getByTestId('compact-filter-pill');
+ await userEvent.click(pill);
+ expect(children).toHaveBeenCalledWith(
+ expect.objectContaining({ isOpen: true, onClose: expect.any(Function) }),
+ );
+});
+
+test('sets aria-haspopup to listbox by default', () => {
+ renderTrigger();
+ const pill = screen.getByTestId('compact-filter-pill');
+ expect(pill).toHaveAttribute('aria-haspopup', 'listbox');
+});
+
+test('sets aria-haspopup to dialog when popupType is dialog', () => {
+ renderTrigger({ popupType: 'dialog' });
+ const pill = screen.getByTestId('compact-filter-pill');
+ expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
+});
+
+test('closing dropdown resets aria-expanded to false', async () => {
+ renderTrigger();
+ const pill = screen.getByTestId('compact-filter-pill');
+ await userEvent.click(pill);
+ expect(pill).toHaveAttribute('aria-expanded', 'true');
+ await userEvent.click(pill);
+ expect(pill).toHaveAttribute('aria-expanded', 'false');
+});
diff --git
a/superset-frontend/src/components/ListView/Filters/CompactFilterTrigger.tsx
b/superset-frontend/src/components/ListView/Filters/CompactFilterTrigger.tsx
new file mode 100644
index 00000000000..4863e9abbbe
--- /dev/null
+++ b/superset-frontend/src/components/ListView/Filters/CompactFilterTrigger.tsx
@@ -0,0 +1,198 @@
+/**
+ * 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 {
+ useEffect,
+ useRef,
+ useState,
+ type ReactNode,
+ type MouseEvent,
+} from 'react';
+import { t } from '@apache-superset/core/translation';
+import { useTheme, styled, css } from '@apache-superset/core/theme';
+import { Dropdown, Tooltip, Icons } from '@superset-ui/core/components';
+
+export type FilterPanelRenderProps = {
+ isOpen: boolean;
+ onClose: () => void;
+};
+
+interface CompactFilterTriggerProps {
+ label: ReactNode;
+ hasValue: boolean;
+ onClear: () => void;
+ /** Render prop: receives { isOpen, onClose } and returns the panel content.
*/
+ children: (props: FilterPanelRenderProps) => ReactNode;
+ /** Shown as a hover tooltip when a value is selected (e.g. the selected
label). */
+ tooltipTitle?: string;
+ /** ARIA popup role for the trigger button. Use 'listbox' for option panels,
+ * 'dialog' for form panels (date range, numerical range). */
+ popupType?: 'listbox' | 'dialog';
+}
+
+const FilterPill = styled.button<{ $active: boolean }>`
+ ${({ theme, $active }) => css`
+ display: inline-flex;
+ align-items: center;
+ gap: ${theme.sizeUnit}px;
+ height: ${theme.controlHeight}px;
+ padding: 0 ${theme.sizeUnit * 3}px;
+ border-radius: ${theme.borderRadius}px;
+ border: 1px solid ${$active ? theme.colorPrimary : theme.colorBorder};
+ background: ${$active ? theme.colorPrimaryBg : theme.colorBgContainer};
+ color: ${$active ? theme.colorPrimary : theme.colorText};
+ font-size: ${theme.fontSizeSM}px;
+ font-weight: ${$active ? 600 : 400};
+ cursor: pointer;
+ white-space: nowrap;
+ line-height: 1;
+ transition:
+ border-color 0.2s,
+ background 0.2s,
+ color 0.2s;
+
+ /* AntD anticon spans carry vertical-align: -0.125em from global styles.
+ align-self centers the span within the pill; the inner flex+align-items
+ centers the svg within the span. */
+ .anticon {
+ display: flex;
+ align-items: center;
+ align-self: center;
+ line-height: 0;
+ }
+
+ &:hover {
+ border-color: ${theme.colorPrimary};
+ background: ${$active ? theme.colorPrimaryBgHover :
theme.colorFillAlter};
+ }
+
+ &:focus-visible {
+ outline: 2px solid ${theme.colorPrimary};
+ outline-offset: 2px;
+ }
+ `}
+`;
+
+const ActiveDot = styled.span`
+ ${({ theme }) => css`
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: ${theme.colorPrimary};
+ flex-shrink: 0;
+ `}
+`;
+
+export default function CompactFilterTrigger({
+ label,
+ hasValue,
+ onClear,
+ children,
+ tooltipTitle,
+ popupType = 'listbox',
+}: CompactFilterTriggerProps) {
+ const [open, setOpen] = useState(false);
+ const [tooltipOpen, setTooltipOpen] = useState(false);
+ const theme = useTheme();
+ // Tracks whether tooltip should be suppressed after dropdown close.
+ // Brave (and some other browsers) fire a synthetic mouseover on
newly-exposed
+ // elements when a popup disappears, triggering Tooltip onOpenChange(true)
+ // without real user intent. We suppress until the cursor actually leaves the
+ // pill (onMouseLeave), which is the first reliable "hover reset" signal.
+ const tooltipSuppressedRef = useRef(false);
+
+ // Close dropdown on window resize — AntD Dropdown doesn't reposition
+ // itself on resize so the panel ends up detached from the pill.
+ useEffect(() => {
+ if (!open) return;
+ const handleResize = () => setOpen(false);
+ window.addEventListener('resize', handleResize);
+ return () => window.removeEventListener('resize', handleResize);
+ }, [open]);
+
+ const handleClear = (e: MouseEvent) => {
+ e.stopPropagation();
+ onClear();
+ setOpen(false);
+ tooltipSuppressedRef.current = true;
+ setTooltipOpen(false);
+ };
+
+ return (
+ <Dropdown
+ open={open}
+ onOpenChange={visible => {
+ setOpen(visible);
+ if (!visible) {
+ tooltipSuppressedRef.current = true;
+ setTooltipOpen(false);
+ }
+ }}
+ trigger={['click']}
+ popupRender={() =>
+ children({ isOpen: open, onClose: () => setOpen(false) })
+ }
+ placement="bottomLeft"
+ destroyPopupOnHide
+ >
+ <Tooltip
+ title={tooltipTitle}
+ open={!!tooltipTitle && !open && tooltipOpen}
+ onOpenChange={visible => {
+ if (visible && tooltipSuppressedRef.current) return;
+ setTooltipOpen(visible && !!tooltipTitle && !open);
+ }}
+ mouseEnterDelay={0.5}
+ mouseLeaveDelay={0}
+ >
+ <FilterPill
+ $active={hasValue}
+ type="button"
+ data-test="compact-filter-pill"
+ aria-haspopup={popupType}
+ aria-expanded={open}
+ aria-label={typeof label === 'string' ? label : undefined}
+ onMouseLeave={() => {
+ tooltipSuppressedRef.current = false;
+ }}
+ >
+ {hasValue && <ActiveDot />}
+ <span>{label}</span>
+ {hasValue ? (
+ <Icons.CloseOutlined
+ iconSize="s"
+ iconColor={theme.colorPrimary}
+ onClick={handleClear}
+ data-test="compact-filter-clear"
+ aria-label={
+ typeof label === 'string'
+ ? t('Clear %s filter', label)
+ : undefined
+ }
+ />
+ ) : (
+ <Icons.DownOutlined
+ iconSize="s"
+ iconColor={theme.colorTextSecondary}
+ />
+ )}
+ </FilterPill>
+ </Tooltip>
+ </Dropdown>
+ );
+}
diff --git
a/superset-frontend/src/components/ListView/Filters/CompactSelectPanel.test.tsx
b/superset-frontend/src/components/ListView/Filters/CompactSelectPanel.test.tsx
new file mode 100644
index 00000000000..01010a574c8
--- /dev/null
+++
b/superset-frontend/src/components/ListView/Filters/CompactSelectPanel.test.tsx
@@ -0,0 +1,339 @@
+/**
+ * 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 { createRef, act } from 'react';
+import { render, screen, waitFor } from 'spec/helpers/testing-library';
+import userEvent from '@testing-library/user-event';
+import CompactSelectPanel from './CompactSelectPanel';
+import type { FilterHandler } from './types';
+
+const SMALL_SELECTS = [
+ { label: 'Alice', value: 1 },
+ { label: 'Bob', value: 2 },
+ { label: 'Charlie', value: 3 },
+];
+
+const LARGE_SELECTS = [
+ { label: 'Alice', value: 1 },
+ { label: 'Bob', value: 2 },
+ { label: 'Charlie', value: 3 },
+ { label: 'David', value: 4 },
+ { label: 'Eve', value: 5 },
+ { label: 'Frank', value: 6 },
+ { label: 'Grace', value: 7 },
+];
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+test('renders options from selects prop', () => {
+ render(
+ <CompactSelectPanel
+ selects={SMALL_SELECTS}
+ value={undefined}
+ onSelect={jest.fn()}
+ />,
+ );
+ expect(screen.getByText('Alice')).toBeInTheDocument();
+ expect(screen.getByText('Bob')).toBeInTheDocument();
+ expect(screen.getByText('Charlie')).toBeInTheDocument();
+});
+
+test('hides search input when selects.length is 6 or fewer', () => {
+ render(
+ <CompactSelectPanel
+ selects={SMALL_SELECTS}
+ value={undefined}
+ onSelect={jest.fn()}
+ />,
+ );
+ expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument();
+});
+
+test('shows search input when selects.length exceeds 6', () => {
+ render(
+ <CompactSelectPanel
+ selects={LARGE_SELECTS}
+ value={undefined}
+ onSelect={jest.fn()}
+ />,
+ );
+ expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
+});
+
+test('shows search input when fetchSelects is provided', () => {
+ const fetchSelects = jest.fn().mockResolvedValue({ data: [], totalCount: 0
});
+ render(
+ <CompactSelectPanel
+ fetchSelects={fetchSelects}
+ value={undefined}
+ onSelect={jest.fn()}
+ />,
+ );
+ expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
+});
+
+test('filters static options by search term', async () => {
+ render(
+ <CompactSelectPanel
+ selects={LARGE_SELECTS}
+ value={undefined}
+ onSelect={jest.fn()}
+ />,
+ );
+ await userEvent.type(screen.getByPlaceholderText('Search'), 'ali');
+ expect(screen.getByText('Alice')).toBeInTheDocument();
+ expect(screen.queryByText('Bob')).not.toBeInTheDocument();
+});
+
+test('calls onSelect with normalized option when an option is clicked', async
() => {
+ const onSelect = jest.fn();
+ render(
+ <CompactSelectPanel
+ selects={SMALL_SELECTS}
+ value={undefined}
+ onSelect={onSelect}
+ />,
+ );
+ await userEvent.click(screen.getByText('Alice'));
+ expect(onSelect).toHaveBeenCalledWith({ label: 'Alice', value: 1 }, false);
+});
+
+test('calls onSelect with undefined when same option is clicked twice
(deselect)', async () => {
+ const onSelect = jest.fn();
+ render(
+ <CompactSelectPanel
+ selects={SMALL_SELECTS}
+ value={{ label: 'Alice', value: 1 }}
+ onSelect={onSelect}
+ />,
+ );
+ await userEvent.click(screen.getByText('Alice'));
+ expect(onSelect).toHaveBeenCalledWith(undefined, true);
+});
+
+test('shows checkmark icon on selected option', () => {
+ render(
+ <CompactSelectPanel
+ selects={SMALL_SELECTS}
+ value={{ label: 'Alice', value: 1 }}
+ onSelect={jest.fn()}
+ />,
+ );
+ const aliceOption = screen
+ .getByText('Alice')
+ .closest('[role="option"]') as HTMLElement;
+ expect(aliceOption).toHaveAttribute('aria-selected', 'true');
+});
+
+test('unselected options have aria-selected false', () => {
+ render(
+ <CompactSelectPanel
+ selects={SMALL_SELECTS}
+ value={{ label: 'Alice', value: 1 }}
+ onSelect={jest.fn()}
+ />,
+ );
+ const bobOption = screen
+ .getByText('Bob')
+ .closest('[role="option"]') as HTMLElement;
+ expect(bobOption).toHaveAttribute('aria-selected', 'false');
+});
+
+test('calls onClose after a selection is made', async () => {
+ const onClose = jest.fn();
+ render(
+ <CompactSelectPanel
+ selects={SMALL_SELECTS}
+ value={undefined}
+ onSelect={jest.fn()}
+ onClose={onClose}
+ />,
+ );
+ await userEvent.click(screen.getByText('Alice'));
+ expect(onClose).toHaveBeenCalledTimes(1);
+});
+
+test('clearFilter via ref resets selection and calls onSelect(undefined,
true)', () => {
+ const onSelect = jest.fn();
+ const ref = createRef<FilterHandler>();
+ const { rerender } = render(
+ <CompactSelectPanel
+ ref={ref}
+ selects={SMALL_SELECTS}
+ value={{ label: 'Alice', value: 1 }}
+ onSelect={onSelect}
+ />,
+ );
+ expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
+ 'aria-selected',
+ 'true',
+ );
+
+ act(() => {
+ ref.current?.clearFilter();
+ });
+
+ expect(onSelect).toHaveBeenCalledWith(undefined, true);
+ // Component is fully controlled — visual deselection follows when the
+ // parent passes value={undefined} after receiving the onSelect callback.
+ rerender(
+ <CompactSelectPanel
+ ref={ref}
+ selects={SMALL_SELECTS}
+ value={undefined}
+ onSelect={onSelect}
+ />,
+ );
+ expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
+ 'aria-selected',
+ 'false',
+ );
+});
+
+test('shows Loading text when loading prop is true', () => {
+ render(
+ <CompactSelectPanel
+ selects={SMALL_SELECTS}
+ value={undefined}
+ onSelect={jest.fn()}
+ loading
+ />,
+ );
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+});
+
+test('shows No results when displayOptions is empty', () => {
+ render(
+ <CompactSelectPanel selects={[]} value={undefined} onSelect={jest.fn()} />,
+ );
+ expect(screen.getByText('No results')).toBeInTheDocument();
+});
+
+test('renders options list with listbox role and accessible label', () => {
+ render(
+ <CompactSelectPanel
+ selects={SMALL_SELECTS}
+ value={undefined}
+ onSelect={jest.fn()}
+ />,
+ );
+ const listbox = screen.getByRole('listbox');
+ expect(listbox).toBeInTheDocument();
+ expect(listbox).toHaveAttribute('aria-label', 'Filter options');
+});
+
+test('option items have option role', () => {
+ render(
+ <CompactSelectPanel
+ selects={SMALL_SELECTS}
+ value={undefined}
+ onSelect={jest.fn()}
+ />,
+ );
+ const options = screen.getAllByRole('option');
+ expect(options).toHaveLength(3);
+});
+
+test('fetches and displays remote options via fetchSelects on mount', async ()
=> {
+ const fetchSelects = jest.fn().mockResolvedValue({
+ data: [{ label: 'Remote User', value: 99 }],
+ totalCount: 1,
+ });
+ render(
+ <CompactSelectPanel
+ fetchSelects={fetchSelects}
+ value={undefined}
+ onSelect={jest.fn()}
+ />,
+ );
+ expect(screen.getByText('Loading...')).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByText('Remote User')).toBeInTheDocument();
+ });
+ expect(fetchSelects).toHaveBeenCalledWith('', 0, 200);
+});
+
+test('shows No results when fetchSelects returns empty data', async () => {
+ const fetchSelects = jest.fn().mockResolvedValue({ data: [], totalCount: 0
});
+ render(
+ <CompactSelectPanel
+ fetchSelects={fetchSelects}
+ value={undefined}
+ onSelect={jest.fn()}
+ />,
+ );
+ await waitFor(() => {
+ expect(screen.getByText('No results')).toBeInTheDocument();
+ });
+});
+
+test('shows No results when fetchSelects rejects', async () => {
+ const fetchSelects = jest.fn().mockRejectedValue(new Error('network error'));
+ render(
+ <CompactSelectPanel
+ fetchSelects={fetchSelects}
+ value={undefined}
+ onSelect={jest.fn()}
+ />,
+ );
+ await waitFor(() => {
+ expect(screen.getByText('No results')).toBeInTheDocument();
+ });
+});
+
+test('selects option via keyboard Enter key', async () => {
+ const onSelect = jest.fn();
+ render(
+ <CompactSelectPanel
+ selects={SMALL_SELECTS}
+ value={undefined}
+ onSelect={onSelect}
+ />,
+ );
+ const aliceOption = screen.getByText('Alice').closest('[role="option"]')!;
+ await userEvent.type(aliceOption, '{Enter}');
+ expect(onSelect).toHaveBeenCalledWith({ label: 'Alice', value: 1 }, false);
+});
+
+test('syncs selected state when external value prop changes', () => {
+ const { rerender } = render(
+ <CompactSelectPanel
+ selects={SMALL_SELECTS}
+ value={{ label: 'Alice', value: 1 }}
+ onSelect={jest.fn()}
+ />,
+ );
+ expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
+ 'aria-selected',
+ 'true',
+ );
+
+ rerender(
+ <CompactSelectPanel
+ selects={SMALL_SELECTS}
+ value={undefined}
+ onSelect={jest.fn()}
+ />,
+ );
+ expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
+ 'aria-selected',
+ 'false',
+ );
+});
diff --git
a/superset-frontend/src/components/ListView/Filters/CompactSelectPanel.tsx
b/superset-frontend/src/components/ListView/Filters/CompactSelectPanel.tsx
new file mode 100644
index 00000000000..872b90e65cf
--- /dev/null
+++ b/superset-frontend/src/components/ListView/Filters/CompactSelectPanel.tsx
@@ -0,0 +1,318 @@
+/**
+ * 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 {
+ forwardRef,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+ useEffect,
+ type CSSProperties,
+ type RefObject,
+} from 'react';
+import { debounce } from 'lodash';
+import { t } from '@apache-superset/core/translation';
+import { useTheme, styled, css } from '@apache-superset/core/theme';
+import {
+ Icons,
+ Input,
+ Constants,
+ type InputRef,
+} from '@superset-ui/core/components';
+import type { SelectOption, ListViewFilter as Filter } from '../types';
+import type { FilterHandler } from './types';
+
+// Show search box when there are more than this many static options.
+const SEARCH_THRESHOLD = 6;
+
+// Page size for async select fetches — large enough to avoid most pagination
+// issues while still being a bounded request. Full infinite-load pagination
+// is a future improvement.
+const ASYNC_PAGE_SIZE = 200;
+
+interface CompactSelectPanelProps {
+ selects?: Filter['selects'];
+ fetchSelects?: Filter['fetchSelects'];
+ value?: SelectOption;
+ onSelect: (option: SelectOption | undefined, isClear?: boolean) => void;
+ onClose?: () => void;
+ isOpen?: boolean;
+ /** Forwarded from the filter config's popupStyle for per-filter width
overrides */
+ panelStyle?: CSSProperties;
+ /** External loading state from filter config */
+ loading?: boolean;
+}
+
+const PanelContainer = styled.div`
+ ${({ theme }) => css`
+ min-width: 220px;
+ max-width: 320px;
+ max-height: 320px;
+ display: flex;
+ flex-direction: column;
+ border-radius: ${theme.borderRadiusLG}px;
+ background: ${theme.colorBgElevated};
+ box-shadow: ${theme.boxShadowSecondary};
+ padding: 0 0 ${theme.paddingXXS}px;
+ `}
+`;
+
+const SearchRow = styled.div`
+ ${({ theme }) => css`
+ padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 2}px
+ ${theme.paddingXXS}px;
+ `}
+`;
+
+const OptionList = styled.ul`
+ ${({ theme }) => css`
+ margin: 0;
+ padding: ${theme.paddingXXS}px 0;
+ overflow-y: auto;
+ flex: 1;
+ list-style: none;
+ `}
+`;
+
+const OptionItem = styled.li<{ $active: boolean }>`
+ ${({ theme, $active }) => css`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: ${(theme.controlHeight - theme.fontSize * theme.lineHeight) / 2}px
+ ${theme.controlPaddingHorizontal}px;
+ line-height: ${theme.lineHeight};
+ cursor: pointer;
+ font-size: ${theme.fontSize}px;
+ color: ${theme.colorText};
+ border-radius: ${theme.borderRadiusSM}px;
+ background: ${$active ? theme.colorPrimaryBg : 'transparent'};
+ transition: background 0.15s;
+
+ &:hover {
+ background: ${$active
+ ? theme.colorPrimaryBgHover
+ : theme.colorFillTertiary};
+ outline: 2px solid ${theme.colorPrimary};
+ outline-offset: -2px;
+ }
+ `}
+`;
+
+const OptionLabel = styled.span`
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ max-width: 240px;
+`;
+
+const StatusText = styled.div`
+ ${({ theme }) => css`
+ padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 3}px;
+ text-align: center;
+ color: ${theme.colorTextDisabled};
+ font-size: ${theme.fontSizeSM}px;
+ `}
+`;
+
+function CompactSelectPanel(
+ {
+ selects = [],
+ fetchSelects,
+ value,
+ onSelect,
+ onClose,
+ isOpen,
+ loading: externalLoading,
+ panelStyle,
+ }: CompactSelectPanelProps,
+ ref: RefObject<FilterHandler>,
+) {
+ const theme = useTheme();
+ const inputRef = useRef<InputRef>(null);
+ const [search, setSearch] = useState('');
+ const [debouncedSearch, setDebouncedSearch] = useState('');
+ const [remoteOptions, setRemoteOptions] = useState<SelectOption[]>([]);
+ const [internalLoading, setInternalLoading] = useState(false);
+
+ const isLoading = externalLoading || internalLoading;
+
+ const debouncedSetSearch = useMemo(
+ () => debounce(setDebouncedSearch, Constants.FAST_DEBOUNCE),
+ [],
+ );
+
+ useEffect(
+ () => () => {
+ debouncedSetSearch.cancel();
+ },
+ [debouncedSetSearch],
+ );
+
+ // Focus search input when dropdown opens; reset search when it closes
+ useEffect(() => {
+ let timeoutId: ReturnType<typeof setTimeout>;
+ if (isOpen) {
+ timeoutId = setTimeout(() => {
+ inputRef.current?.input?.focus({ preventScroll: true });
+ }, 100);
+ } else {
+ setSearch('');
+ setDebouncedSearch('');
+ }
+ return () => {
+ if (timeoutId) clearTimeout(timeoutId);
+ };
+ }, [isOpen]);
+
+ // Fetch remote options when debounced search changes
+ useEffect(() => {
+ if (!fetchSelects) return;
+ let cancelled = false;
+ setInternalLoading(true);
+ fetchSelects(debouncedSearch, 0, ASYNC_PAGE_SIZE)
+ .then(result => {
+ if (!cancelled) setRemoteOptions(result?.data ?? []);
+ })
+ .catch(() => {
+ if (!cancelled) setRemoteOptions([]);
+ })
+ .finally(() => {
+ if (!cancelled) setInternalLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [debouncedSearch, fetchSelects]);
+
+ useImperativeHandle(ref, () => ({
+ clearFilter: () => {
+ setSearch('');
+ setDebouncedSearch('');
+ onSelect(undefined, true);
+ },
+ }));
+
+ const displayOptions = (
+ fetchSelects
+ ? remoteOptions
+ : selects.filter(o => {
+ const label = typeof o.label === 'string' ? o.label :
String(o.value);
+ return label.toLowerCase().includes(search.toLowerCase());
+ })
+ ).filter(o => o != null);
+
+ const showSearch = !!fetchSelects || selects.length > SEARCH_THRESHOLD;
+
+ const handleSelect = (opt: SelectOption, displayText?: string) => {
+ const isDeselect = value?.value === opt.value;
+ // Normalize to a plain string label for URL serialization:
+ // 1. String labels pass through unchanged.
+ // 2. ReactNode labels with a `title` field use that (set by callers for
+ // options like owner-select where label contains name + email JSX).
+ // 3. Fall back to DOM text content, then stringified value.
+ const label =
+ typeof opt.label === 'string'
+ ? opt.label
+ : (opt.title ?? displayText ?? String(opt.value ?? ''));
+ const next = isDeselect ? undefined : { label, value: opt.value };
+ onSelect(next, isDeselect);
+ onClose?.();
+ };
+
+ return (
+ <PanelContainer style={panelStyle}>
+ {showSearch && (
+ <SearchRow>
+ <Input
+ ref={inputRef}
+ prefix={
+ <Icons.SearchOutlined iconSize="l" iconColor={theme.colorIcon} />
+ }
+ placeholder={t('Search')}
+ value={search}
+ onChange={e => {
+ setSearch(e.target.value);
+ debouncedSetSearch(e.target.value);
+ }}
+ allowClear
+ css={css`
+ width: 100%;
+ box-shadow: none;
+ `}
+ />
+ </SearchRow>
+ )}
+ <OptionList role="listbox" aria-label={t('Filter options')}>
+ {isLoading ? (
+ <StatusText>{t('Loading...')}</StatusText>
+ ) : displayOptions.length === 0 ? (
+ <StatusText>{t('No results')}</StatusText>
+ ) : (
+ displayOptions.map((opt, i) => {
+ const isActive = value?.value === opt.value;
+ const getDisplayText = (el: HTMLElement) =>
+ el.textContent?.trim() || undefined;
+ const isFirst = i === 0;
+ const isLast = i === displayOptions.length - 1;
+ return (
+ <OptionItem
+ key={opt.value}
+ $active={isActive}
+ role="option"
+ aria-selected={isActive}
+ tabIndex={0}
+ onClick={e =>
+ handleSelect(opt, getDisplayText(e.currentTarget))
+ }
+ onKeyDown={e => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ handleSelect(opt, getDisplayText(e.currentTarget));
+ } else if (e.key === 'ArrowDown' && !isLast) {
+ e.preventDefault();
+ (
+ e.currentTarget.nextElementSibling as HTMLElement | null
+ )?.focus();
+ } else if (e.key === 'ArrowUp' && !isFirst) {
+ e.preventDefault();
+ (
+ e.currentTarget
+ .previousElementSibling as HTMLElement | null
+ )?.focus();
+ }
+ }}
+ >
+ <OptionLabel>{opt.label}</OptionLabel>
+ {isActive && (
+ <Icons.CheckOutlined
+ iconSize="s"
+ iconColor={theme.colorPrimary}
+ />
+ )}
+ </OptionItem>
+ );
+ })
+ )}
+ </OptionList>
+ </PanelContainer>
+ );
+}
+
+export default forwardRef(CompactSelectPanel);
diff --git a/superset-frontend/src/components/ListView/Filters/DateRange.tsx
b/superset-frontend/src/components/ListView/Filters/DateRange.tsx
deleted file mode 100644
index 1b735a5c9b0..00000000000
--- a/superset-frontend/src/components/ListView/Filters/DateRange.tsx
+++ /dev/null
@@ -1,112 +0,0 @@
-/**
- * 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 {
- useState,
- useMemo,
- forwardRef,
- useImperativeHandle,
- RefObject,
-} from 'react';
-
-import { t } from '@apache-superset/core/translation';
-import { Dayjs } from 'dayjs';
-import { useLocale } from 'src/hooks/useLocale';
-import { extendedDayjs } from '@superset-ui/core/utils/dates';
-import {
- AntdThemeProvider,
- Loading,
- FormLabel,
- RangePicker,
-} from '@superset-ui/core/components';
-import type { BaseFilter, FilterHandler } from './types';
-import { FilterContainer } from './Base';
-import { RANGE_WIDTH } from '../utils';
-
-interface DateRangeFilterProps extends BaseFilter {
- onSubmit: (val: number[] | string[]) => void;
- name: string;
- dateFilterValueType?: 'unix' | 'iso';
-}
-
-type ValueState = [number, number] | [string, string] | null;
-
-function DateRangeFilter(
- {
- Header,
- initialValue,
- onSubmit,
- dateFilterValueType = 'unix',
- }: DateRangeFilterProps,
- ref: RefObject<FilterHandler>,
-) {
- const [value, setValue] = useState<ValueState | null>(initialValue ?? null);
- const dayjsValue = useMemo((): [Dayjs, Dayjs] | null => {
- if (!value || (Array.isArray(value) && !value.length)) return null;
- return [extendedDayjs(value[0]), extendedDayjs(value[1])];
- }, [value]);
-
- const locale = useLocale();
-
- useImperativeHandle(ref, () => ({
- clearFilter: () => {
- setValue(null);
- onSubmit([]);
- },
- }));
-
- if (locale === null) {
- return <Loading position="inline-centered" />;
- }
- return (
- <AntdThemeProvider locale={locale}>
- <FilterContainer
- data-test="date-range-filter-container"
- vertical
- justify="center"
- align="start"
- width={RANGE_WIDTH}
- >
- <FormLabel>{Header}</FormLabel>
- <RangePicker
- placeholder={[t('Start date'), t('End date')]}
- showTime
- value={dayjsValue}
- onCalendarChange={(dayjsRange: [Dayjs, Dayjs]) => {
- if (!dayjsRange?.[0]?.valueOf() || !dayjsRange?.[1]?.valueOf()) {
- setValue(null);
- onSubmit([]);
- return;
- }
- const changeValue =
- dateFilterValueType === 'iso'
- ? [dayjsRange[0].toISOString(), dayjsRange[1].toISOString()]
- : [
- dayjsRange[0]?.valueOf() ?? 0,
- dayjsRange[1]?.valueOf() ?? 0,
- ];
- setValue(changeValue as ValueState);
- onSubmit(changeValue);
- }}
- />
- </FilterContainer>
- </AntdThemeProvider>
- );
-}
-
-export default forwardRef(DateRangeFilter);
diff --git
a/superset-frontend/src/components/ListView/Filters/FilterPopoverContent.test.tsx
b/superset-frontend/src/components/ListView/Filters/FilterPopoverContent.test.tsx
new file mode 100644
index 00000000000..f863f78d498
--- /dev/null
+++
b/superset-frontend/src/components/ListView/Filters/FilterPopoverContent.test.tsx
@@ -0,0 +1,80 @@
+/**
+ * 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 { render, screen } from 'spec/helpers/testing-library';
+import userEvent from '@testing-library/user-event';
+import FilterPopoverContent from './FilterPopoverContent';
+
+beforeEach(() => {
+ jest.clearAllMocks();
+});
+
+test('renders children inside the wrapper', () => {
+ render(
+ <FilterPopoverContent>
+ <div data-test="inner-content">Inner content</div>
+ </FilterPopoverContent>,
+ );
+ expect(screen.getByTestId('inner-content')).toBeInTheDocument();
+});
+
+test('renders the Apply button', () => {
+ render(
+ <FilterPopoverContent>
+ <div>content</div>
+ </FilterPopoverContent>,
+ );
+ expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
+});
+
+test('calls onClose when Apply button is clicked', async () => {
+ const onClose = jest.fn();
+ render(
+ <FilterPopoverContent onClose={onClose}>
+ <div>content</div>
+ </FilterPopoverContent>,
+ );
+ await userEvent.click(screen.getByRole('button', { name: /apply/i }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+});
+
+test('renders without onClose and clicking Apply does not throw', async () => {
+ render(
+ <FilterPopoverContent>
+ <div>content</div>
+ </FilterPopoverContent>,
+ );
+ // No onClose prop — click should not throw
+ await userEvent.click(screen.getByRole('button', { name: /apply/i }));
+ expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
+});
+
+test('visually hides label elements so pills remain accessible', () => {
+ render(
+ <FilterPopoverContent>
+ <label htmlFor="input">Date range</label>
+ <input id="input" />
+ </FilterPopoverContent>,
+ );
+ const label = screen.getByText('Date range');
+ // The label must be in the DOM for screen readers but visually hidden via
CSS
+ expect(label).toBeInTheDocument();
+ const computedStyle = window.getComputedStyle(label);
+ // clip / overflow hidden pattern applied; position absolute is the key
indicator
+ expect(computedStyle.position).toBe('absolute');
+});
diff --git
a/superset-frontend/src/components/ListView/Filters/FilterPopoverContent.tsx
b/superset-frontend/src/components/ListView/Filters/FilterPopoverContent.tsx
new file mode 100644
index 00000000000..4d9ea3ae032
--- /dev/null
+++ b/superset-frontend/src/components/ListView/Filters/FilterPopoverContent.tsx
@@ -0,0 +1,74 @@
+/**
+ * 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 type { ReactNode } from 'react';
+import { t } from '@apache-superset/core/translation';
+import { styled, css } from '@apache-superset/core/theme';
+import { Button } from '@superset-ui/core/components';
+
+interface FilterPopoverContentProps {
+ children: ReactNode;
+ onClose?: () => void;
+}
+
+const Wrapper = styled.div`
+ ${({ theme }) => css`
+ padding: ${theme.sizeUnit * 2}px;
+ display: flex;
+ flex-direction: column;
+ gap: ${theme.sizeUnit * 2}px;
+ background: ${theme.colorBgElevated};
+ border-radius: ${theme.borderRadiusLG}px;
+ box-shadow: ${theme.boxShadowSecondary};
+
+ /* Visually hide the redundant label — the pill already shows it, but keep
it
+ accessible to screen readers so filter inputs have a named context. */
+ label {
+ position: absolute !important;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+ }
+ `}
+`;
+
+const Footer = styled.div`
+ display: flex;
+ justify-content: flex-end;
+`;
+
+export default function FilterPopoverContent({
+ children,
+ onClose,
+}: FilterPopoverContentProps) {
+ return (
+ <Wrapper>
+ {children}
+ <Footer>
+ <Button size="small" buttonStyle="primary" onClick={onClose}>
+ {t('Apply')}
+ </Button>
+ </Footer>
+ </Wrapper>
+ );
+}
diff --git a/superset-frontend/src/components/ListView/Filters/Select.test.tsx
b/superset-frontend/src/components/ListView/Filters/Select.test.tsx
deleted file mode 100644
index a0bedb32f20..00000000000
--- a/superset-frontend/src/components/ListView/Filters/Select.test.tsx
+++ /dev/null
@@ -1,267 +0,0 @@
-/**
- * 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 { createRef } from 'react';
-import {
- render,
- screen,
- selectOption,
- waitFor,
-} from 'spec/helpers/testing-library';
-import { ListViewFilterOperator } from '../types';
-import UIFilters from './index';
-import SelectFilter from './Select';
-import type { FilterHandler } from './types';
-
-const mockUpdateFilterValue = jest.fn();
-
-beforeEach(() => {
- mockUpdateFilterValue.mockClear();
-});
-
-test('select filter with ReactNode label uses option title when serializing
selection', async () => {
- // Regression for sc-104554: the chart-list Owner filter renders options
- // with ReactNode labels (name + email). The value passed to
- // updateFilterValue is serialized into URL / filter state and re-used to
- // render the filter pill on return. It must carry the plain-text name
- // (from `title`) and not fall back to the numeric user id.
- const ReactNodeLabel = (
- <div>
- <span>John Doe</span>
- <span>[email protected]</span>
- </div>
- );
-
- const fetchSelects = jest.fn().mockResolvedValue({
- data: [
- {
- label: ReactNodeLabel,
- value: 42,
- title: 'John Doe',
- },
- ],
- totalCount: 1,
- });
-
- const filters = [
- {
- Header: 'Owner',
- key: 'owner',
- id: 'owners',
- input: 'select' as const,
- operator: ListViewFilterOperator.RelationManyMany,
- unfilteredLabel: 'All',
- fetchSelects,
- paginate: true,
- },
- ];
-
- render(
- <UIFilters
- filters={filters}
- internalFilters={[]}
- updateFilterValue={mockUpdateFilterValue}
- />,
- );
-
- await selectOption('John Doe', 'Owner');
-
- await waitFor(() => {
- expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
- label: 'John Doe',
- value: 42,
- });
- });
-});
-
-test('select filter falls back to stringified value when no string label or
title is available', async () => {
- const fetchSelects = jest.fn().mockResolvedValue({
- data: [
- {
- label: <span>123</span>,
- value: 123,
- },
- ],
- totalCount: 1,
- });
-
- const filters = [
- {
- Header: 'Something',
- key: 'something',
- id: 'something',
- input: 'select' as const,
- operator: ListViewFilterOperator.RelationOneMany,
- unfilteredLabel: 'All',
- fetchSelects,
- },
- ];
-
- render(
- <UIFilters
- filters={filters}
- internalFilters={[]}
- updateFilterValue={mockUpdateFilterValue}
- />,
- );
-
- await selectOption('123', 'Something');
-
- await waitFor(() => {
- expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
- label: '123',
- value: 123,
- });
- });
-});
-
-test('plain select with string label passes label through unchanged', async ()
=> {
- // Happy-path coverage for the typeof-string branch in onChange, exercised
- // through the non-async Select wrapper (selects array, no fetchSelects).
- const filters = [
- {
- Header: 'Status',
- key: 'status',
- id: 'status',
- input: 'select' as const,
- operator: ListViewFilterOperator.Equals,
- unfilteredLabel: 'All',
- selects: [
- { label: 'Published', value: 7 },
- { label: 'Draft', value: 8 },
- ],
- },
- ];
-
- render(
- <UIFilters
- filters={filters}
- internalFilters={[]}
- updateFilterValue={mockUpdateFilterValue}
- />,
- );
-
- await selectOption('Published', 'Status');
-
- await waitFor(() => {
- expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
- label: 'Published',
- value: 7,
- });
- });
-});
-
-test('plain select with ReactNode label uses option title when serializing
selection', async () => {
- // Parallel coverage to the AsyncSelect ReactNode-with-title test, against
- // the non-async Select wrapper. Guards against the two wrappers ever
- // diverging on antd's two-arg onChange shape.
- const ReactNodeLabel = (
- <div>
- <span>Jane Roe</span>
- <span>[email protected]</span>
- </div>
- );
-
- const filters = [
- {
- Header: 'Owner',
- key: 'owner',
- id: 'owners',
- input: 'select' as const,
- operator: ListViewFilterOperator.RelationManyMany,
- unfilteredLabel: 'All',
- selects: [{ label: ReactNodeLabel, value: 99, title: 'Jane Roe' }],
- },
- ];
-
- render(
- <UIFilters
- filters={filters}
- internalFilters={[]}
- updateFilterValue={mockUpdateFilterValue}
- />,
- );
-
- await selectOption('Jane Roe', 'Owner');
-
- await waitFor(() => {
- expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
- label: 'Jane Roe',
- value: 99,
- });
- });
-});
-
-test('clearFilter notifies onSelect with undefined and isClear=true', () => {
- // The isClear flag is what allows the parent (Filters/index) to suppress
- // onFilterUpdate side-effects when the user clears the filter rather than
- // picking a new value. Lock that contract in.
- const mockOnSelect = jest.fn();
- const ref = createRef<FilterHandler>();
-
- render(
- <SelectFilter
- Header="Owner"
- initialValue={{ label: 'John Doe', value: 42 }}
- onSelect={mockOnSelect}
- selects={[{ label: 'John Doe', value: 42, title: 'John Doe' }]}
- ref={ref}
- />,
- );
-
- ref.current?.clearFilter();
-
- expect(mockOnSelect).toHaveBeenCalledWith(undefined, true);
-});
-
-test('rehydrates filter pill from initialValue with plain-string label', async
() => {
- // The user-visible regression: after URL/state rehydration the filter pill
- // must render the human-readable name, not the numeric user id. The fix
- // ensures the persisted label is a string; this test asserts that string
- // is what surfaces in the rendered combobox selection.
- const filters = [
- {
- Header: 'Owner',
- key: 'owner',
- id: 'owners',
- input: 'select' as const,
- operator: ListViewFilterOperator.RelationManyMany,
- unfilteredLabel: 'All',
- fetchSelects: jest.fn().mockResolvedValue({ data: [], totalCount: 0 }),
- paginate: true,
- },
- ];
-
- render(
- <UIFilters
- filters={filters}
- internalFilters={[
- {
- id: 'owners',
- operator: ListViewFilterOperator.RelationManyMany,
- value: { label: 'John Doe', value: 42 },
- },
- ]}
- updateFilterValue={mockUpdateFilterValue}
- />,
- );
-
- await waitFor(() => {
- expect(screen.getByText('John Doe')).toBeInTheDocument();
- });
-});
diff --git a/superset-frontend/src/components/ListView/Filters/Select.tsx
b/superset-frontend/src/components/ListView/Filters/Select.tsx
deleted file mode 100644
index cecee38a274..00000000000
--- a/superset-frontend/src/components/ListView/Filters/Select.tsx
+++ /dev/null
@@ -1,154 +0,0 @@
-/**
- * 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 {
- useState,
- useMemo,
- forwardRef,
- useImperativeHandle,
- type RefObject,
-} from 'react';
-
-import { t } from '@apache-superset/core/translation';
-import { Select, AsyncSelect, FormLabel } from '@superset-ui/core/components';
-import { ListViewFilter as Filter, SelectOption } from '../types';
-import type { BaseFilter, FilterHandler } from './types';
-import { FilterContainer } from './Base';
-import { SELECT_WIDTH } from '../utils';
-
-interface SelectFilterProps extends BaseFilter {
- fetchSelects?: Filter['fetchSelects'];
- name?: string;
- onSelect: (selected: SelectOption | undefined, isClear?: boolean) => void;
- optionFilterProps?: string[];
- paginate?: boolean;
- selects: Filter['selects'];
- loading?: boolean;
- dropdownStyle?: React.CSSProperties;
-}
-
-function SelectFilter(
- {
- Header,
- name,
- fetchSelects,
- initialValue,
- onSelect,
- optionFilterProps,
- selects = [],
- loading = false,
- dropdownStyle,
- }: SelectFilterProps,
- ref: RefObject<FilterHandler>,
-) {
- const [selectedOption, setSelectedOption] = useState(initialValue);
-
- const onChange = (selected: SelectOption, option?: SelectOption) => {
- // antd's `onChange` (with `labelInValue`) passes the `{label, value}`
- // labeled-value as the first arg and the full option (which carries
- // `title` and any other fields) as the second. Options may supply a
- // ReactNode label (e.g. OwnerSelectLabel for the chart list Owner
- // filter). Since this object is serialized into the URL and rehydrated
- // as the filter pill on return, we need a plain string. Prefer `title`
- // (set by callers to the human-readable name) before falling back to
- // the value.
- onSelect(
- selected
- ? {
- label:
- typeof selected.label === 'string'
- ? selected.label
- : (option?.title ?? String(selected.value)),
- value: selected.value,
- }
- : undefined,
- );
- setSelectedOption(selected);
- };
-
- const onClear = () => {
- onSelect(undefined, true);
- setSelectedOption(undefined);
- };
-
- useImperativeHandle(ref, () => ({
- clearFilter: () => {
- onClear();
- },
- }));
-
- const fetchAndFormatSelects = useMemo(
- () => async (inputValue: string, page: number, pageSize: number) => {
- if (fetchSelects) {
- const selectValues = await fetchSelects(inputValue, page, pageSize);
- return {
- data: selectValues.data,
- totalCount: selectValues.totalCount,
- };
- }
- return {
- data: [],
- totalCount: 0,
- };
- },
- [fetchSelects],
- );
- const placeholder = t('Choose...');
- return (
- <FilterContainer
- data-test="select-filter-container"
- width={SELECT_WIDTH}
- vertical
- justify="center"
- align="start"
- >
- <FormLabel>{Header}</FormLabel>
- {fetchSelects ? (
- <AsyncSelect
- allowClear
- ariaLabel={typeof Header === 'string' ? Header : name || t('Filter')}
- data-test="filters-select"
- onChange={onChange}
- onClear={onClear}
- options={fetchAndFormatSelects}
- optionFilterProps={optionFilterProps}
- placeholder={placeholder}
- dropdownStyle={dropdownStyle}
- showSearch
- value={selectedOption}
- />
- ) : (
- <Select
- allowClear
- ariaLabel={typeof Header === 'string' ? Header : name || t('Filter')}
- data-test="filters-select"
- labelInValue
- onChange={onChange}
- onClear={onClear}
- options={selects}
- placeholder={placeholder}
- dropdownStyle={dropdownStyle}
- showSearch
- value={selectedOption}
- loading={loading}
- />
- )}
- </FilterContainer>
- );
-}
-export default forwardRef(SelectFilter);
diff --git
a/superset-frontend/src/components/ListView/Filters/TimeRange.test.tsx
b/superset-frontend/src/components/ListView/Filters/TimeRange.test.tsx
new file mode 100644
index 00000000000..63b315f7f5b
--- /dev/null
+++ b/superset-frontend/src/components/ListView/Filters/TimeRange.test.tsx
@@ -0,0 +1,251 @@
+/**
+ * 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 { createRef, act } from 'react';
+import { render, screen, waitFor } from 'spec/helpers/testing-library';
+import userEvent from '@testing-library/user-event';
+import { NO_TIME_RANGE, SupersetClient } from '@superset-ui/core';
+import TimeRangeFilter from './TimeRange';
+import type { FilterHandler } from './types';
+
+// Suppress debounced evaluation — the initial useEffect handles the committed
+// value; the debounced path is an optimistic UX enhancement, not a contract.
+jest.mock('src/explore/exploreUtils', () => ({
+ ...jest.requireActual('src/explore/exploreUtils'),
+ useDebouncedEffect: jest.fn(),
+}));
+
+jest.mock('src/explore/components/controls/DateFilterControl/utils', () => ({
+ FRAME_OPTIONS: [
+ { label: 'No filter', value: 'No filter' },
+ { label: 'Custom', value: 'Custom' },
+ ],
+ guessFrame: jest.fn().mockReturnValue('Custom'),
+ // 'No filter' is the string value of NO_TIME_RANGE constant
+ useDefaultTimeFilter: jest.fn().mockReturnValue('No filter'),
+}));
+
+jest.mock(
+ 'src/explore/components/controls/DateFilterControl/components',
+ () => ({
+ AdvancedFrame: () => <div data-test="advanced-frame" />,
+ CalendarFrame: () => <div data-test="calendar-frame" />,
+ CommonFrame: () => <div data-test="common-frame" />,
+ CustomFrame: ({ value }: { value: string }) => (
+ <div data-test="custom-frame">{value}</div>
+ ),
+ }),
+);
+
+jest.mock(
+
'src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame',
+ () => ({
+ CurrentCalendarFrame: () => <div data-testid="current-calendar-frame" />,
+ }),
+);
+
+const VALID_RANGE = '2024-01-01 : 2024-01-31';
+
+// Default successful response that fetchTimeRange and the Apply handler both
use
+const MOCK_TIME_RANGE_RESULT = {
+ json: {
+ result: [{ since: '2024-01-01T00:00:00', until: '2024-01-31T23:59:59' }],
+ },
+};
+
+let getSpy: jest.SpyInstance;
+
+beforeEach(() => {
+ getSpy = jest
+ .spyOn(SupersetClient, 'get')
+ .mockResolvedValue(MOCK_TIME_RANGE_RESULT as any);
+});
+
+afterEach(() => {
+ getSpy.mockRestore();
+});
+
+function renderFilter(
+ props: Partial<{
+ value: string;
+ onSubmit: jest.Mock;
+ onClose: jest.Mock;
+ }> = {},
+) {
+ const onSubmit = props.onSubmit ?? jest.fn();
+ const onClose = props.onClose ?? jest.fn();
+ return render(
+ <TimeRangeFilter
+ value={props.value ?? VALID_RANGE}
+ onSubmit={onSubmit}
+ onClose={onClose}
+ />,
+ );
+}
+
+test('renders range type label, actual time range section, and footer
buttons', () => {
+ renderFilter();
+ expect(screen.getByText('Range type')).toBeInTheDocument();
+ expect(screen.getByText('Actual time range')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
+});
+
+test('shows the custom frame when guessFrame returns Custom', () => {
+ renderFilter();
+ expect(screen.getByTestId('custom-frame')).toBeInTheDocument();
+});
+
+test('Apply is disabled until the API validates the initial value', async ()
=> {
+ // Block resolution so we can observe disabled state
+ let resolve: (v: typeof MOCK_TIME_RANGE_RESULT) => void;
+ getSpy.mockReturnValue(
+ new Promise(res => {
+ resolve = res;
+ }),
+ );
+
+ renderFilter();
+ const apply = screen.getByRole('button', { name: /apply/i });
+ expect(apply).toBeDisabled();
+
+ act(() => {
+ resolve!(MOCK_TIME_RANGE_RESULT);
+ });
+
+ await waitFor(() => {
+ expect(apply).not.toBeDisabled();
+ });
+});
+
+test('Apply is enabled when the API returns a valid result', async () => {
+ renderFilter();
+ const apply = screen.getByRole('button', { name: /apply/i });
+ await waitFor(() => {
+ expect(apply).not.toBeDisabled();
+ });
+});
+
+test('Apply is disabled when the API returns an error response', async () => {
+ getSpy.mockRejectedValue(new Error('Bad request'));
+ renderFilter();
+ const apply = screen.getByRole('button', { name: /apply/i });
+ // Give fetchTimeRange time to reject and set validTimeRange=false
+ await waitFor(() => {
+ expect(apply).toBeDisabled();
+ });
+});
+
+test('Cancel button calls onClose', async () => {
+ const onClose = jest.fn();
+ renderFilter({ onClose });
+ await userEvent.click(screen.getByRole('button', { name: /cancel/i }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+});
+
+test('Apply calls onSubmit([since, until]) and onClose when API succeeds',
async () => {
+ const onSubmit = jest.fn();
+ const onClose = jest.fn();
+
+ renderFilter({ onSubmit, onClose });
+
+ const apply = screen.getByRole('button', { name: /apply/i });
+ await waitFor(() => {
+ expect(apply).not.toBeDisabled();
+ });
+
+ await userEvent.click(apply);
+
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalledWith([
+ '2024-01-01T00:00:00',
+ '2024-01-31T23:59:59',
+ ]);
+ });
+ expect(onClose).toHaveBeenCalledTimes(1);
+});
+
+test('Apply calls onClose but not onSubmit when the API call throws', async ()
=> {
+ const onSubmit = jest.fn();
+ const onClose = jest.fn();
+
+ // fetchTimeRange succeeds (for validTimeRange), but the Apply API call fails
+ getSpy
+ .mockResolvedValueOnce(MOCK_TIME_RANGE_RESULT as any) // fetchTimeRange in
useEffect
+ .mockRejectedValueOnce(new Error('network')); // Apply button API call
+
+ renderFilter({ onSubmit, onClose });
+
+ const apply = screen.getByRole('button', { name: /apply/i });
+ await waitFor(() => {
+ expect(apply).not.toBeDisabled();
+ });
+
+ await userEvent.click(apply);
+
+ await waitFor(() => {
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+ expect(onSubmit).not.toHaveBeenCalled();
+});
+
+test('Apply with NO_TIME_RANGE calls onSubmit(undefined) and onClose without
an API call', async () => {
+ const onSubmit = jest.fn();
+ const onClose = jest.fn();
+
+ render(
+ <TimeRangeFilter
+ value={NO_TIME_RANGE}
+ onSubmit={onSubmit}
+ onClose={onClose}
+ />,
+ );
+
+ const apply = screen.getByRole('button', { name: /apply/i });
+ await waitFor(() => {
+ expect(apply).not.toBeDisabled();
+ });
+
+ const callsBefore = getSpy.mock.calls.length;
+ await userEvent.click(apply);
+
+ expect(onSubmit).toHaveBeenCalledWith(undefined);
+ expect(onClose).toHaveBeenCalledTimes(1);
+ // No extra API call for NO_TIME_RANGE — the button short-circuits
+ expect(getSpy.mock.calls.length).toBe(callsBefore);
+});
+
+test('clearFilter via ref calls onSubmit(undefined)', async () => {
+ const onSubmit = jest.fn();
+ const ref = createRef<FilterHandler>();
+
+ render(
+ <TimeRangeFilter
+ ref={ref}
+ value={VALID_RANGE}
+ onSubmit={onSubmit}
+ onClose={jest.fn()}
+ />,
+ );
+
+ act(() => {
+ ref.current?.clearFilter();
+ });
+
+ expect(onSubmit).toHaveBeenCalledWith(undefined);
+});
diff --git a/superset-frontend/src/components/ListView/Filters/TimeRange.tsx
b/superset-frontend/src/components/ListView/Filters/TimeRange.tsx
new file mode 100644
index 00000000000..8ecb3009588
--- /dev/null
+++ b/superset-frontend/src/components/ListView/Filters/TimeRange.tsx
@@ -0,0 +1,291 @@
+/**
+ * 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 {
+ forwardRef,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useState,
+ type RefObject,
+} from 'react';
+import { t } from '@apache-superset/core/translation';
+import {
+ NO_TIME_RANGE,
+ SupersetClient,
+ fetchTimeRange,
+} from '@superset-ui/core';
+import rison from 'rison';
+import { css, styled, useTheme } from '@apache-superset/core/theme';
+import {
+ Button,
+ Constants,
+ Divider,
+ Icons,
+ Select,
+} from '@superset-ui/core/components';
+import { useDebouncedEffect } from 'src/explore/exploreUtils';
+import {
+ FRAME_OPTIONS,
+ guessFrame,
+ useDefaultTimeFilter,
+} from 'src/explore/components/controls/DateFilterControl/utils';
+import {
+ AdvancedFrame,
+ CalendarFrame,
+ CommonFrame,
+ CustomFrame,
+} from 'src/explore/components/controls/DateFilterControl/components';
+import { CurrentCalendarFrame } from
'src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame';
+import type { FrameType } from
'src/explore/components/controls/DateFilterControl/types';
+import type { FilterHandler } from './types';
+
+interface TimeRangeFilterProps {
+ value?: string;
+ onSubmit: (value: [string, string] | undefined) => void;
+ onClose: () => void;
+}
+
+const StyledRangeType = styled(Select)`
+ width: 272px;
+`;
+
+const ContentWrapper = styled.div`
+ ${({ theme }) => css`
+ width: 600px;
+ padding: ${theme.sizeUnit * 3}px;
+ background: ${theme.colorBgElevated};
+ border-radius: ${theme.borderRadiusLG}px;
+ box-shadow: ${theme.boxShadowSecondary};
+
+ .ant-row {
+ margin-top: 8px;
+ }
+
+ .ant-picker {
+ padding: 4px 17px 4px;
+ border-radius: 4px;
+ }
+
+ .ant-divider-horizontal {
+ margin: 16px 0;
+ }
+
+ .control-label {
+ font-size: ${theme.fontSizeSM}px;
+ line-height: 16px;
+ margin: 8px 0;
+ }
+
+ .section-title {
+ font-style: normal;
+ font-weight: ${theme.fontWeightStrong};
+ font-size: 15px;
+ line-height: 24px;
+ margin-bottom: 8px;
+ }
+
+ .control-anchor-to {
+ margin-top: 16px;
+ }
+
+ .control-anchor-to-datetime {
+ width: 217px;
+ }
+
+ .footer {
+ text-align: right;
+ }
+ `}
+`;
+
+const IconWrapper = styled.span`
+ span {
+ margin-right: ${({ theme }) => 2 * theme.sizeUnit}px;
+ vertical-align: middle;
+ }
+ .text {
+ vertical-align: middle;
+ }
+ .error {
+ color: ${({ theme }) => theme.colorError};
+ }
+`;
+
+function TimeRangeFilter(
+ { value: valueProp, onSubmit, onClose }: TimeRangeFilterProps,
+ ref: RefObject<FilterHandler>,
+) {
+ const defaultTimeFilter = useDefaultTimeFilter();
+ const value = valueProp ?? defaultTimeFilter;
+ const theme = useTheme();
+
+ // guessedFrame is only used for the initial useState — value is stable at
+ // mount because CompactFilterTrigger uses destroyPopupOnHide, so the panel
+ // always mounts fresh with the current committed value.
+ const guessedFrame = useMemo(() => guessFrame(value), [value]);
+ const [frame, setFrame] = useState<FrameType>(guessedFrame);
+ const [timeRangeValue, setTimeRangeValue] = useState(value);
+ const [evalResponse, setEvalResponse] = useState(value);
+ const [validTimeRange, setValidTimeRange] = useState(false);
+ const [lastFetched, setLastFetched] = useState(value);
+
+ // Evaluate the committed value shown in "Actual time range".
+ useEffect(() => {
+ if (value === NO_TIME_RANGE) {
+ setEvalResponse(NO_TIME_RANGE);
+ setValidTimeRange(true);
+ return;
+ }
+ fetchTimeRange(value).then(({ value: actual, error }) => {
+ if (error) {
+ setEvalResponse(error ?? '');
+ setValidTimeRange(false);
+ } else {
+ setEvalResponse(actual ?? value);
+ setValidTimeRange(true);
+ }
+ setLastFetched(value);
+ });
+ }, [value]);
+
+ // Debounced evaluation of the in-progress selection (drives "Actual time
range").
+ useDebouncedEffect(
+ () => {
+ if (timeRangeValue === NO_TIME_RANGE) {
+ setEvalResponse(NO_TIME_RANGE);
+ setLastFetched(NO_TIME_RANGE);
+ setValidTimeRange(true);
+ return;
+ }
+ if (lastFetched !== timeRangeValue) {
+ fetchTimeRange(timeRangeValue).then(({ value: actual, error }) => {
+ if (error) {
+ setEvalResponse(error ?? '');
+ setValidTimeRange(false);
+ } else {
+ setEvalResponse(actual ?? '');
+ setValidTimeRange(true);
+ }
+ setLastFetched(timeRangeValue);
+ });
+ }
+ },
+ Constants.SLOW_DEBOUNCE,
+ [timeRangeValue],
+ );
+
+ useImperativeHandle(ref, () => ({
+ clearFilter: () => {
+ onSubmit(undefined);
+ },
+ }));
+
+ function onChangeFrame(val: FrameType) {
+ if (val === NO_TIME_RANGE) {
+ setTimeRangeValue(NO_TIME_RANGE);
+ }
+ setFrame(val);
+ }
+
+ return (
+ <ContentWrapper>
+ <div className="control-label">{t('Range type')}</div>
+ <StyledRangeType
+ ariaLabel={t('Range type')}
+ options={FRAME_OPTIONS}
+ value={frame}
+ onChange={onChangeFrame}
+ />
+ {frame !== 'No filter' && <Divider />}
+ {frame === 'Common' && (
+ <CommonFrame value={timeRangeValue} onChange={setTimeRangeValue} />
+ )}
+ {frame === 'Calendar' && (
+ <CalendarFrame value={timeRangeValue} onChange={setTimeRangeValue} />
+ )}
+ {frame === 'Current' && (
+ <CurrentCalendarFrame
+ value={timeRangeValue}
+ onChange={setTimeRangeValue}
+ />
+ )}
+ {frame === 'Advanced' && (
+ <AdvancedFrame value={timeRangeValue} onChange={setTimeRangeValue} />
+ )}
+ {frame === 'Custom' && (
+ <CustomFrame value={timeRangeValue} onChange={setTimeRangeValue} />
+ )}
+ <Divider />
+ <div>
+ <div className="section-title">{t('Actual time range')}</div>
+ {validTimeRange && (
+ <div>
+ {evalResponse === NO_TIME_RANGE ? t('No filter') : evalResponse}
+ </div>
+ )}
+ {!validTimeRange && (
+ <IconWrapper className="warning">
+ <Icons.ExclamationCircleOutlined iconColor={theme.colorError} />
+ <span className="text error">{evalResponse}</span>
+ </IconWrapper>
+ )}
+ </div>
+ <Divider />
+ <div className="footer">
+ <Button buttonStyle="secondary" cta key="cancel" onClick={onClose}>
+ {t('CANCEL')}
+ </Button>
+ <Button
+ buttonStyle="primary"
+ cta
+ disabled={!validTimeRange}
+ key="apply"
+ onClick={async () => {
+ if (timeRangeValue === NO_TIME_RANGE) {
+ onSubmit(undefined);
+ onClose();
+ return;
+ }
+ // fetchTimeRange returns a formatted display string ("X ≤ col <
Y"),
+ // not the raw since/until strings. Call the API directly to get
them.
+ try {
+ const response = await SupersetClient.get({
+ endpoint:
`/api/v1/time_range/?q=${rison.encode_uri(timeRangeValue)}`,
+ });
+ const since: string | undefined =
+ response?.json?.result[0]?.since;
+ const until: string | undefined =
+ response?.json?.result[0]?.until;
+ if (since !== undefined && until !== undefined) {
+ onSubmit([since, until]);
+ }
+ } catch {
+ // leave filter unchanged on error
+ }
+ onClose();
+ }}
+ >
+ {t('APPLY')}
+ </Button>
+ </div>
+ </ContentWrapper>
+ );
+}
+
+export default forwardRef(TimeRangeFilter);
diff --git a/superset-frontend/src/components/ListView/Filters/index.test.tsx
b/superset-frontend/src/components/ListView/Filters/index.test.tsx
index 1d75dbe9178..c93e3540b4b 100644
--- a/superset-frontend/src/components/ListView/Filters/index.test.tsx
+++ b/superset-frontend/src/components/ListView/Filters/index.test.tsx
@@ -17,6 +17,7 @@
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
+import userEvent from '@testing-library/user-event';
import { ListViewFilterOperator } from '../types';
import UIFilters from './index';
@@ -97,7 +98,335 @@ test('search filter passes autoComplete prop correctly', ()
=> {
expect(input.autocomplete).toBe('new-password');
});
-test('renders multiple search filters with different inputName values', () => {
+test('renders a compact pill trigger for select filters', () => {
+ const filters = [
+ {
+ Header: 'Owner',
+ key: 'owner',
+ id: 'owner',
+ input: 'select' as const,
+ operator: ListViewFilterOperator.RelationOneMany,
+ selects: [
+ { label: 'Alice', value: 1 },
+ { label: 'Bob', value: 2 },
+ ],
+ },
+ ];
+
+ render(
+ <UIFilters
+ filters={filters}
+ internalFilters={[]}
+ updateFilterValue={mockUpdateFilterValue}
+ />,
+ );
+
+ expect(screen.getByTestId('compact-filter-pill')).toBeInTheDocument();
+ expect(screen.getByText('Owner')).toBeInTheDocument();
+});
+
+test('select pill shows active state (clear button) when a value is selected',
() => {
+ const filters = [
+ {
+ Header: 'Owner',
+ key: 'owner',
+ id: 'owner',
+ input: 'select' as const,
+ operator: ListViewFilterOperator.RelationOneMany,
+ selects: [{ label: 'Alice', value: 1 }],
+ },
+ ];
+
+ render(
+ <UIFilters
+ filters={filters}
+ internalFilters={[
+ {
+ id: 'owner',
+ operator: ListViewFilterOperator.RelationOneMany,
+ value: { label: 'Alice', value: 1 },
+ },
+ ]}
+ updateFilterValue={mockUpdateFilterValue}
+ />,
+ );
+
+ expect(
+ screen.getByRole('button', { name: /clear owner filter/i }),
+ ).toBeInTheDocument();
+});
+
+test('select pill tooltip falls back to static selects on cold URL load (no
cached label)', () => {
+ const filters = [
+ {
+ Header: 'Owner',
+ key: 'owner',
+ id: 'owner',
+ input: 'select' as const,
+ operator: ListViewFilterOperator.RelationOneMany,
+ selects: [
+ { label: 'Alice', value: 1 },
+ { label: 'Bob', value: 2 },
+ ],
+ },
+ ];
+
+ // Simulate cold URL load: value has only numeric value, no label in cache
+ render(
+ <UIFilters
+ filters={filters}
+ internalFilters={[
+ {
+ id: 'owner',
+ operator: ListViewFilterOperator.RelationOneMany,
+ value: { value: 1 } as any,
+ },
+ ]}
+ updateFilterValue={mockUpdateFilterValue}
+ />,
+ );
+
+ // The pill should be active (clear button visible) and the static label
+ // should be resolved as the tooltip source
+ expect(
+ screen.getByRole('button', { name: /clear owner filter/i }),
+ ).toBeInTheDocument();
+});
+
+test('datetime_range filter renders as CompactFilterTrigger with dialog
aria-haspopup', () => {
+ const filters = [
+ {
+ Header: 'Time range',
+ key: 'time_range',
+ id: 'time_range',
+ input: 'datetime_range' as const,
+ operator: ListViewFilterOperator.Between,
+ },
+ ];
+
+ render(
+ <UIFilters
+ filters={filters}
+ internalFilters={[]}
+ updateFilterValue={mockUpdateFilterValue}
+ />,
+ );
+
+ const pill = screen.getByTestId('compact-filter-pill');
+ expect(pill).toBeInTheDocument();
+ expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
+ expect(screen.getByText('Time range')).toBeInTheDocument();
+});
+
+test('datetime_range pill shows active state when a time range string is set',
() => {
+ const filters = [
+ {
+ Header: 'Time range',
+ key: 'time_range',
+ id: 'time_range',
+ input: 'datetime_range' as const,
+ operator: ListViewFilterOperator.Between,
+ },
+ ];
+
+ render(
+ <UIFilters
+ filters={filters}
+ internalFilters={[
+ {
+ id: 'time_range',
+ operator: ListViewFilterOperator.Between,
+ value: 'Last week',
+ },
+ ]}
+ updateFilterValue={mockUpdateFilterValue}
+ />,
+ );
+
+ // Clear icon is inside the pill (not a separate button)
+ const pill = screen.getByTestId('compact-filter-pill');
+ const clearIcon = screen.getByTestId('compact-filter-clear');
+ expect(clearIcon).toBeInTheDocument();
+ expect(pill).toContainElement(clearIcon);
+});
+
+test('datetime_range pill is inactive when value is NO_TIME_RANGE', () => {
+ const filters = [
+ {
+ Header: 'Time range',
+ key: 'time_range',
+ id: 'time_range',
+ input: 'datetime_range' as const,
+ operator: ListViewFilterOperator.Between,
+ },
+ ];
+
+ render(
+ <UIFilters
+ filters={filters}
+ internalFilters={[
+ {
+ id: 'time_range',
+ operator: ListViewFilterOperator.Between,
+ value: 'No filter',
+ },
+ ]}
+ updateFilterValue={mockUpdateFilterValue}
+ />,
+ );
+
+ expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
+});
+
+test('datetime_range pill shows the time range string as tooltip title', () =>
{
+ const filters = [
+ {
+ Header: 'Time range',
+ key: 'time_range',
+ id: 'time_range',
+ input: 'datetime_range' as const,
+ operator: ListViewFilterOperator.Between,
+ },
+ ];
+
+ render(
+ <UIFilters
+ filters={filters}
+ internalFilters={[
+ {
+ id: 'time_range',
+ operator: ListViewFilterOperator.Between,
+ value: 'Last month',
+ },
+ ]}
+ updateFilterValue={mockUpdateFilterValue}
+ />,
+ );
+
+ // Pill is active and clear icon is inside
+ expect(screen.getByTestId('compact-filter-clear')).toBeInTheDocument();
+});
+
+test('numerical_range filter renders as CompactFilterTrigger with dialog
aria-haspopup', () => {
+ const filters = [
+ {
+ Header: 'Age range',
+ key: 'age_range',
+ id: 'age_range',
+ input: 'numerical_range' as const,
+ operator: ListViewFilterOperator.Between,
+ },
+ ];
+
+ render(
+ <UIFilters
+ filters={filters}
+ internalFilters={[]}
+ updateFilterValue={mockUpdateFilterValue}
+ />,
+ );
+
+ const pill = screen.getByTestId('compact-filter-pill');
+ expect(pill).toBeInTheDocument();
+ expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
+ expect(screen.getByText('Age range')).toBeInTheDocument();
+});
+
+test('numerical_range pill shows active state when value is set', () => {
+ const filters = [
+ {
+ Header: 'Age range',
+ key: 'age_range',
+ id: 'age_range',
+ input: 'numerical_range' as const,
+ operator: ListViewFilterOperator.Between,
+ },
+ ];
+
+ render(
+ <UIFilters
+ filters={filters}
+ internalFilters={[
+ {
+ id: 'age_range',
+ operator: ListViewFilterOperator.Between,
+ value: [18, 65],
+ },
+ ]}
+ updateFilterValue={mockUpdateFilterValue}
+ />,
+ );
+
+ expect(
+ screen.getByRole('button', { name: /clear age range filter/i }),
+ ).toBeInTheDocument();
+});
+
+test('datetime_range onClear calls updateFilterValue with undefined directly',
async () => {
+ const updateFilterValue = jest.fn();
+ const filters = [
+ {
+ Header: 'Time range',
+ key: 'time_range',
+ id: 'time_range',
+ input: 'datetime_range' as const,
+ operator: ListViewFilterOperator.Between,
+ },
+ ];
+
+ render(
+ <UIFilters
+ filters={filters}
+ internalFilters={[
+ {
+ id: 'time_range',
+ operator: ListViewFilterOperator.Between,
+ value: 'Last week',
+ },
+ ]}
+ updateFilterValue={updateFilterValue}
+ />,
+ );
+
+ const clearIcon = screen.getByTestId('compact-filter-clear');
+ await userEvent.click(clearIcon);
+ expect(updateFilterValue).toHaveBeenCalledWith(0, undefined);
+});
+
+test('numerical_range onClear calls updateFilterValue with undefined
directly', async () => {
+ const updateFilterValue = jest.fn();
+ const filters = [
+ {
+ Header: 'Age range',
+ key: 'age_range',
+ id: 'age_range',
+ input: 'numerical_range' as const,
+ operator: ListViewFilterOperator.Between,
+ },
+ ];
+
+ render(
+ <UIFilters
+ filters={filters}
+ internalFilters={[
+ {
+ id: 'age_range',
+ operator: ListViewFilterOperator.Between,
+ value: [18, 65],
+ },
+ ]}
+ updateFilterValue={updateFilterValue}
+ />,
+ );
+
+ const clearBtn = screen.getByRole('button', {
+ name: /clear age range filter/i,
+ });
+ await userEvent.click(clearBtn);
+ expect(updateFilterValue).toHaveBeenCalledWith(0, undefined);
+});
+
+test('renders only the first search filter when multiple search filters are
configured', () => {
const filters = [
{
Header: 'Name',
@@ -125,8 +454,8 @@ test('renders multiple search filters with different
inputName values', () => {
/>,
);
+ // Only the first search filter renders — one search box per page
const inputs = screen.getAllByTestId('filters-search') as HTMLInputElement[];
- expect(inputs).toHaveLength(2);
+ expect(inputs).toHaveLength(1);
expect(inputs[0].name).toBe('filter_name_search');
- expect(inputs[1].name).toBe('description');
});
diff --git a/superset-frontend/src/components/ListView/Filters/index.tsx
b/superset-frontend/src/components/ListView/Filters/index.tsx
index b76c9e4bc2c..07642aa3275 100644
--- a/superset-frontend/src/components/ListView/Filters/index.tsx
+++ b/superset-frontend/src/components/ListView/Filters/index.tsx
@@ -19,12 +19,16 @@
import {
createRef,
forwardRef,
+ useCallback,
+ useEffect,
useImperativeHandle,
useMemo,
+ useState,
RefObject,
} from 'react';
import { withTheme } from '@apache-superset/core/theme';
+import { t } from '@apache-superset/core/translation';
import type {
ListViewFilterValue as FilterValue,
@@ -33,10 +37,13 @@ import type {
SelectOption,
} from '../types';
import type { FilterHandler } from './types';
+import { NO_TIME_RANGE } from '@superset-ui/core';
import SearchFilter from './Search';
-import SelectFilter from './Select';
-import DateRangeFilter from './DateRange';
import NumericalRangeFilter from './NumericalRange';
+import TimeRangeFilter from './TimeRange';
+import CompactFilterTrigger from './CompactFilterTrigger';
+import CompactSelectPanel from './CompactSelectPanel';
+import FilterPopoverContent from './FilterPopoverContent';
interface UIFiltersProps {
filters: Filters;
@@ -46,7 +53,10 @@ interface UIFiltersProps {
function UIFilters(
{ filters, internalFilters = [], updateFilterValue }: UIFiltersProps,
- ref: RefObject<{ clearFilters: () => void }>,
+ ref: RefObject<{
+ clearFilters: () => void;
+ clearFilterById: (id: string) => void;
+ }>,
) {
const filterRefs = useMemo(
() =>
@@ -54,125 +64,320 @@ function UIFilters(
[filters.length],
);
+ // Cache display labels for select filters so tooltip works after URL
round-trip
+ // (URL serialization strips the label, leaving only the value).
+ const [tooltipLabels, setTooltipLabels] = useState<Record<number, string>>(
+ {},
+ );
+
+ // Evaluated human-readable labels for datetime_range pills (e.g.
"2024-05-01 : 2024-05-31").
+ const [timeRangeTooltips, setTimeRangeTooltips] = useState<
+ Record<number, string>
+ >({});
+
+ // On cold load, URL params restore values but not labels for fetchSelects
filters.
+ // Fetch the first page of options and cache the matching label so the
tooltip works.
+ useEffect(() => {
+ filters.forEach((filter, index) => {
+ if (filter.input !== 'select' || !filter.fetchSelects) return;
+ if (tooltipLabels[index]) return;
+ const val = internalFilters?.[index]?.value as SelectOption | undefined;
+ if (!val?.value) return;
+ filter.fetchSelects('', 0, 500).then(result => {
+ const match = result?.data?.find(
+ (s: SelectOption) => s.value === val.value,
+ );
+ if (match) {
+ const lbl =
+ typeof match.label === 'string'
+ ? match.label
+ : String(match.value ?? '');
+ setTooltipLabels(prev => ({ ...prev, [index]: lbl }));
+ }
+ });
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [internalFilters]);
+
+ // Build datetime_range tooltips from the resolved [start, end] array value.
+ // Handles both ISO strings and unix-ms numbers.
+ useEffect(() => {
+ filters.forEach((filter, index) => {
+ if (filter.input !== 'datetime_range') return;
+ const val = internalFilters?.[index]?.value;
+ if (Array.isArray(val) && val.length === 2) {
+ const fmt = (v: unknown) => {
+ const d = new Date(v as string | number);
+ return isNaN(d.getTime())
+ ? String(v)
+ : d.toISOString().replace('T', ' ').slice(0, 19);
+ };
+ const tooltip = `${fmt(val[0])} – ${fmt(val[1])}`;
+ setTimeRangeTooltips(prev =>
+ prev[index] === tooltip ? prev : { ...prev, [index]: tooltip },
+ );
+ } else {
+ setTimeRangeTooltips(prev => {
+ if (!(index in prev)) return prev;
+ const next = { ...prev };
+ delete next[index];
+ return next;
+ });
+ }
+ });
+ }, [filters, internalFilters]);
+
+ const clearFilterAtIndex = useCallback(
+ (index: number) => {
+ filterRefs[index]?.current?.clearFilter?.();
+ updateFilterValue(index, undefined);
+ setTooltipLabels(prev => {
+ const next = { ...prev };
+ delete next[index];
+ return next;
+ });
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [updateFilterValue],
+ );
+
useImperativeHandle(ref, () => ({
clearFilters: () => {
- filterRefs.forEach((filter: any) => {
- filter.current?.clearFilter?.();
+ filterRefs.forEach((_, index) => {
+ filterRefs[index]?.current?.clearFilter?.();
+ updateFilterValue(index, undefined);
});
+ setTooltipLabels({});
+ setTimeRangeTooltips({});
},
clearFilterById: (id: string) => {
const index = filters.findIndex(f => f.id === id);
if (index >= 0) {
- filterRefs[index]?.current?.clearFilter?.();
+ clearFilterAtIndex(index);
}
},
}));
- return (
- <>
- {filters.map(
- (
- {
- Header,
- fetchSelects,
- key,
- id,
- input,
- optionFilterProps,
- paginate,
- selects,
- toolTipDescription,
- onFilterUpdate,
- loading,
- dateFilterValueType,
- min,
- max,
- popupStyle,
- autoComplete,
- inputName,
- },
- index,
- ) => {
- const initialValue = internalFilters?.[index]?.value;
- if (input === 'select') {
- return (
- <SelectFilter
+ // Search always leads the filter bar regardless of declaration order.
+ // Only the first search filter renders; subsequent ones are skipped (see
note below).
+ // NOTE: This means secondary search fields (e.g. Email/Username on Users,
+ // Group Key on RLS) are not currently accessible via the filter bar. Those
+ // pages previously relied on multiple inline inputs. This is a known UX
+ // trade-off — revisit if admin workflows require additional search fields.
+ let searchFilterRendered = false;
+
+ // Render in two passes: search first, then all other filter types.
+ const renderFilter = (_: (typeof filters)[number], index: number) => {
+ const {
+ Header,
+ fetchSelects,
+ key,
+ id,
+ input,
+ selects,
+ toolTipDescription,
+ onFilterUpdate,
+ loading,
+ min,
+ max,
+ autoComplete,
+ inputName,
+ popupStyle,
+ dateFilterValueType,
+ } = filters[index];
+ const initialValue = internalFilters?.[index]?.value;
+ if (input === 'select') {
+ const selectValue = initialValue as SelectOption | undefined;
+ // Prefer cached label (survives URL round-trips where only the value
+ // is preserved). Fall back to the static selects list for cold loads.
+ const cachedLabel = tooltipLabels[index];
+ const staticFallback = cachedLabel
+ ? undefined
+ : selects?.find(s => s.value === selectValue?.value)?.label;
+ const tooltipTitle = !!selectValue
+ ? cachedLabel ||
+ (typeof staticFallback === 'string' ? staticFallback : undefined)
+ : t('Choose...');
+ return (
+ <span key={key} data-test="select-filter-container">
+ <CompactFilterTrigger
+ label={Header}
+ hasValue={!!selectValue}
+ tooltipTitle={tooltipTitle}
+ onClear={() => clearFilterAtIndex(index)}
+ >
+ {({ isOpen, onClose }) => (
+ <CompactSelectPanel
ref={filterRefs[index]}
- Header={Header}
+ selects={selects}
fetchSelects={fetchSelects}
- initialValue={initialValue}
- key={key}
- name={id}
+ value={initialValue as SelectOption | undefined}
+ loading={loading ?? false}
+ isOpen={isOpen}
+ onClose={onClose}
+ panelStyle={popupStyle}
onSelect={(
option: SelectOption | undefined,
isClear?: boolean,
) => {
- if (onFilterUpdate) {
- // Filter change triggers both onChange AND onClear, only
want to track onChange
- if (!isClear) {
- onFilterUpdate(option);
- }
+ if (option && !isClear) {
+ setTooltipLabels(prev => ({
+ ...prev,
+ [index]:
+ typeof option.label === 'string'
+ ? option.label
+ : String(option.value ?? ''),
+ }));
+ }
+ if (onFilterUpdate && !isClear) {
+ onFilterUpdate(option);
}
-
updateFilterValue(index, option);
}}
- optionFilterProps={optionFilterProps}
- paginate={paginate}
- selects={selects}
- loading={loading ?? false}
- dropdownStyle={popupStyle}
/>
- );
- }
- if (input === 'search' && typeof Header === 'string') {
- return (
- <SearchFilter
- ref={filterRefs[index]}
- Header={Header}
- initialValue={initialValue}
- key={key}
- name={inputName ?? id}
- toolTipDescription={toolTipDescription}
- onSubmit={(value: string) => {
- if (onFilterUpdate) {
- onFilterUpdate(value);
- }
+ )}
+ </CompactFilterTrigger>
+ </span>
+ );
+ }
+ if (input === 'search' && typeof Header === 'string') {
+ if (searchFilterRendered) return null;
+ searchFilterRendered = true;
+ return (
+ <SearchFilter
+ ref={filterRefs[index]}
+ Header={Header}
+ initialValue={initialValue}
+ key={key}
+ name={inputName ?? id}
+ toolTipDescription={toolTipDescription}
+ onSubmit={(value: string) => {
+ if (onFilterUpdate) {
+ onFilterUpdate(value);
+ }
- updateFilterValue(index, value);
- }}
- autoComplete={autoComplete}
- />
- );
- }
- if (input === 'datetime_range') {
- return (
- <DateRangeFilter
- ref={filterRefs[index]}
- Header={Header}
- initialValue={initialValue}
- key={key}
- name={id}
- onSubmit={value => updateFilterValue(index, value)}
- dateFilterValueType={dateFilterValueType || 'unix'}
- />
- );
+ updateFilterValue(index, value);
+ }}
+ autoComplete={autoComplete}
+ />
+ );
+ }
+ if (input === 'datetime_range') {
+ // dateFilterValueType absent or 'unix': column stores unix ms (e.g.
Query History start_time).
+ // 'iso': column stores ISO date strings (e.g. UsersList created_on,
ActionLog dttm).
+ const isUnixType = !dateFilterValueType || dateFilterValueType ===
'unix';
+
+ // initialValue may be [ms, ms] (unix), ["iso","iso"] (iso), or legacy
string.
+ // Always reconstruct panelValue as "ISO : ISO" so the TimeRange panel
+ // can parse it as a Custom date range regardless of storage type.
+ let resolvedIsoRange: [string, string] | null = null;
+ if (Array.isArray(initialValue) && initialValue.length === 2) {
+ if (typeof initialValue[0] === 'number') {
+ resolvedIsoRange = [
+ new Date(initialValue[0]).toISOString(),
+ new Date(initialValue[1] as number).toISOString(),
+ ];
+ } else if (typeof initialValue[0] === 'string') {
+ resolvedIsoRange = initialValue as [string, string];
+ }
+ }
+ const legacyStringVal =
+ !resolvedIsoRange &&
+ typeof initialValue === 'string' &&
+ initialValue !== NO_TIME_RANGE
+ ? initialValue
+ : null;
+ const hasTimeValue = !!(resolvedIsoRange || legacyStringVal);
+ const panelValue =
+ resolvedIsoRange?.join(' : ') ?? legacyStringVal ?? undefined;
+ return (
+ <CompactFilterTrigger
+ key={key}
+ label={Header}
+ hasValue={hasTimeValue}
+ tooltipTitle={
+ hasTimeValue ? (timeRangeTooltips[index] ?? panelValue) : undefined
}
- if (input === 'numerical_range') {
- return (
+ popupType="dialog"
+ onClear={() => {
+ updateFilterValue(index, undefined);
+ }}
+ >
+ {({ onClose }) => (
+ <TimeRangeFilter
+ ref={filterRefs[index]}
+ value={panelValue}
+ onClose={onClose}
+ onSubmit={value => {
+ if (!value) {
+ updateFilterValue(index, undefined);
+ } else if (isUnixType) {
+ // Convert ISO strings to unix ms for numeric columns
+ updateFilterValue(index, [
+ new Date(value[0]).getTime(),
+ new Date(value[1]).getTime(),
+ ]);
+ } else {
+ updateFilterValue(index, value);
+ }
+ }}
+ />
+ )}
+ </CompactFilterTrigger>
+ );
+ }
+ if (input === 'numerical_range') {
+ const hasRangeValue =
+ Array.isArray(initialValue) &&
+ initialValue.some(v => v !== null && v !== undefined);
+ const rangeTooltip = hasRangeValue
+ ? (initialValue as (number | null | undefined)[])
+ .filter(v => v !== null && v !== undefined)
+ .join(' – ')
+ : undefined;
+ return (
+ <CompactFilterTrigger
+ key={key}
+ label={Header}
+ hasValue={hasRangeValue}
+ tooltipTitle={rangeTooltip}
+ popupType="dialog"
+ onClear={() => {
+ updateFilterValue(index, undefined);
+ }}
+ >
+ {({ onClose }) => (
+ <FilterPopoverContent onClose={onClose}>
<NumericalRangeFilter
ref={filterRefs[index]}
Header={Header}
initialValue={initialValue}
min={min}
max={max}
- key={key}
name={id}
onSubmit={value => updateFilterValue(index, value)}
/>
- );
- }
- return null;
- },
+ </FilterPopoverContent>
+ )}
+ </CompactFilterTrigger>
+ );
+ }
+ return null;
+ };
+
+ return (
+ <>
+ {/* Search first */}
+ {filters.map((_, index) =>
+ filters[index].input === 'search'
+ ? renderFilter(filters[index], index)
+ : null,
+ )}
+ {/* Then all other filter types */}
+ {filters.map((_, index) =>
+ filters[index].input !== 'search'
+ ? renderFilter(filters[index], index)
+ : null,
)}
</>
);
diff --git a/superset-frontend/src/components/ListView/ListView.test.tsx
b/superset-frontend/src/components/ListView/ListView.test.tsx
index f278e607ac6..0d08bcf9fd9 100644
--- a/superset-frontend/src/components/ListView/ListView.test.tsx
+++ b/superset-frontend/src/components/ListView/ListView.test.tsx
@@ -301,15 +301,19 @@ describe('ListView', () => {
});
test('renders UI filters', () => {
- const filterControls = screen.getAllByRole('combobox');
- expect(filterControls).toHaveLength(2);
+ // select and datetime_range filters render as compact pill buttons;
+ // search filter renders as a text input
+ const filterPills = screen.getAllByTestId('compact-filter-pill');
+ expect(filterPills).toHaveLength(3); // ID, Age, Time
});
test('calls fetchData on filter', async () => {
- // Handle select filter
- const selectFilter = screen.getAllByRole('combobox')[0];
- await userEvent.click(selectFilter);
- const option = screen.getByText('foo');
+ // Click the ID compact pill to open its option panel
+ const idPill = screen.getByRole('button', { name: 'ID' });
+ await userEvent.click(idPill);
+
+ // Wait for and click the 'foo' option in the dropdown panel
+ const option = await screen.findByRole('option', { name: 'foo' });
await userEvent.click(option);
// Handle search filter
@@ -341,7 +345,10 @@ describe('ListView', () => {
initialSort: [{ id: 'something' }],
});
- const sortSelect = screen.getByTestId('card-sort-select');
+ const sortSelectContainer = screen.getByTestId('card-sort-select');
+ const sortSelect = sortSelectContainer.querySelector(
+ '[data-test="compact-filter-pill"]',
+ ) as HTMLElement;
await userEvent.click(sortSelect);
const sortOption = screen.getByText('Alphabetical');
diff --git a/superset-frontend/src/components/ListView/ListView.tsx
b/superset-frontend/src/components/ListView/ListView.tsx
index 9c1f313bd68..b77c402606e 100644
--- a/superset-frontend/src/components/ListView/ListView.tsx
+++ b/superset-frontend/src/components/ListView/ListView.tsx
@@ -65,13 +65,43 @@ const ListViewStyles = styled.div`
.header {
display: flex;
+ align-items: center;
padding-bottom: ${theme.sizeUnit * 4}px;
& .controls {
display: flex;
flex-wrap: wrap;
- column-gap: ${theme.sizeUnit * 7}px;
- row-gap: ${theme.sizeUnit * 4}px;
+ align-items: center;
+ column-gap: ${theme.sizeUnit * 2}px;
+ row-gap: ${theme.sizeUnit * 2}px;
+
+ /* Search input — fixed width/height matching pill height, label
hidden */
+ [data-test='search-filter-container'] {
+ width: ${theme.sizeUnit * 44}px;
+ flex-shrink: 0;
+ height: ${theme.controlHeight}px;
+ align-self: center;
+ /* Hide the FormLabel Flex wrapper entirely so it doesn't affect
+ the column's justify-content centering calculation. */
+ > .ant-flex {
+ display: none;
+ }
+ label {
+ display: none;
+ }
+ .ant-input-affix-wrapper {
+ width: 100%;
+ height: 100%;
+ }
+ }
+
+ /* Select filter pill wrappers — make them proper flex items so the
+ inline-flex button inside doesn't introduce line-box quirks. */
+ [data-test='select-filter-container'] {
+ display: flex;
+ align-items: center;
+ align-self: center;
+ }
}
}
@@ -167,7 +197,6 @@ const bulkSelectColumnConfig = {
const ViewModeContainer = styled.div`
${({ theme }) => `
padding-right: ${theme.sizeUnit * 4}px;
- margin-top: ${theme.sizeUnit * 5 + 1}px;
white-space: nowrap;
display: inline-block;
@@ -192,6 +221,29 @@ const ViewModeContainer = styled.div`
`}
`;
+const ClearAllButton = styled.button`
+ ${({ theme }) => `
+ background: none;
+ border: none;
+ padding: 0 ${theme.sizeUnit}px;
+ color: ${theme.colorPrimary};
+ font-size: ${theme.fontSizeSM}px;
+ cursor: pointer;
+ white-space: nowrap;
+ line-height: ${theme.controlHeight}px;
+
+ &:hover:not(:disabled) {
+ color: ${theme.colorPrimaryHover};
+ text-decoration: underline;
+ }
+
+ &:disabled {
+ color: ${theme.colorTextDisabled};
+ cursor: not-allowed;
+ }
+ `}
+`;
+
const EmptyWrapper = styled.div`
${({ theme }) => `
padding: ${theme.sizeUnit * 40}px 0;
@@ -356,6 +408,14 @@ export function ListView<T extends object = any>({
clearFilterById: (id: string) => void;
}>(null);
+ const hasActiveFilters = internalFilters.some(f => {
+ if (f.value === null || f.value === undefined || f.value === '')
+ return false;
+ if (Array.isArray(f.value))
+ return f.value.some(v => v !== null && v !== undefined && v !== '');
+ return true;
+ });
+
// Wire the optional external filtersRef to our internal filterControlsRef.
// useLayoutEffect fires synchronously after DOM mutations, guaranteeing the
// ref is populated before the first paint and after every update.
@@ -421,6 +481,21 @@ export function ListView<T extends object = any>({
options={cardSortSelectOptions}
/>
)}
+ {filterable && (
+ <Tooltip
+ title={!hasActiveFilters ? t('No filters applied') : undefined}
+ >
+ <span>
+ <ClearAllButton
+ type="button"
+ disabled={!hasActiveFilters}
+ onClick={() => filterControlsRef.current?.clearFilters()}
+ >
+ {t('Clear all')}
+ </ClearAllButton>
+ </span>
+ </Tooltip>
+ )}
</div>
</div>
<div className={`body ${rows.length === 0 ? 'empty' : ''} `}>
diff --git a/superset-frontend/src/pages/ChartList/ChartList.test.tsx
b/superset-frontend/src/pages/ChartList/ChartList.test.tsx
index 6f9dedc5a09..1b16c7674b7 100644
--- a/superset-frontend/src/pages/ChartList/ChartList.test.tsx
+++ b/superset-frontend/src/pages/ChartList/ChartList.test.tsx
@@ -57,9 +57,12 @@ const mockUser = {
const findFilterByLabel = (labelText: string) => {
const containers = screen.getAllByTestId('select-filter-container');
for (const container of containers) {
- const label = container.querySelector('label');
- if (label?.textContent === labelText) {
- return container.querySelector('[role="combobox"], .ant-select');
+ // Compact pill filters show the label as button text
+ const pill = container.querySelector(
+ '[data-test="compact-filter-pill"]',
+ ) as HTMLElement | null;
+ if (pill && pill.textContent?.includes(labelText)) {
+ return pill;
}
}
return null;
diff --git
a/superset-frontend/src/pages/DashboardList/DashboardList.cardview.test.tsx
b/superset-frontend/src/pages/DashboardList/DashboardList.cardview.test.tsx
index c03299cfc43..7733251176d 100644
--- a/superset-frontend/src/pages/DashboardList/DashboardList.cardview.test.tsx
+++ b/superset-frontend/src/pages/DashboardList/DashboardList.cardview.test.tsx
@@ -156,18 +156,16 @@ describe('DashboardList Card View Tests', () => {
).toBeInTheDocument();
});
- // Find the sort select by its testId, then the combobox within it
+ // Find the sort select by its testId, then the pill button within it
const sortContainer = screen.getByTestId('card-sort-select');
- const sortCombobox = within(sortContainer).getByRole('combobox');
- await userEvent.click(sortCombobox);
+ // eslint-disable-next-line testing-library/no-node-access
+ const sortPill = sortContainer.querySelector(
+ '[data-test="compact-filter-pill"]',
+ ) as HTMLElement;
+ await userEvent.click(sortPill);
// Select "Alphabetical" from the dropdown
- const alphabeticalOption = await waitFor(() =>
- within(
- // eslint-disable-next-line testing-library/no-node-access
- document.querySelector('.rc-virtual-list')!,
- ).getByText('Alphabetical'),
- );
+ const alphabeticalOption = await screen.findByText('Alphabetical');
await userEvent.click(alphabeticalOption);
await waitFor(() => {
diff --git a/superset-frontend/src/pages/DashboardList/DashboardList.test.tsx
b/superset-frontend/src/pages/DashboardList/DashboardList.test.tsx
index 2f4b8f9dc5e..87c974df801 100644
--- a/superset-frontend/src/pages/DashboardList/DashboardList.test.tsx
+++ b/superset-frontend/src/pages/DashboardList/DashboardList.test.tsx
@@ -20,7 +20,7 @@ import fetchMock from 'fetch-mock';
import { isFeatureEnabled } from '@superset-ui/core';
import {
screen,
- selectOption,
+ selectPillOption,
waitFor,
fireEvent,
} from 'spec/helpers/testing-library';
@@ -200,7 +200,7 @@ test('selecting Status filter encodes published=true in API
call', async () => {
).toBeInTheDocument();
});
- await selectOption('Published', 'Status');
+ await selectPillOption('Published', 'Status');
await waitFor(() => {
const latest = getLatestDashboardApiCall();
@@ -242,7 +242,7 @@ test('selecting Owner filter encodes rel_m_m owner in API
call', async () => {
).toBeInTheDocument();
});
- await selectOption('Admin User', 'Owner');
+ await selectPillOption('Admin User', 'Owner');
await waitFor(() => {
const latest = getLatestDashboardApiCall();
@@ -287,7 +287,7 @@ test('selecting Modified by filter encodes rel_o_m
changed_by in API call', asyn
).toBeInTheDocument();
});
- await selectOption('Admin User', 'Modified by');
+ await selectPillOption('Admin User', 'Modified by');
await waitFor(() => {
const latest = getLatestDashboardApiCall();
diff --git
a/superset-frontend/src/pages/DatasetList/DatasetList.integration.test.tsx
b/superset-frontend/src/pages/DatasetList/DatasetList.integration.test.tsx
index 2a28b7bb11c..ee654a77458 100644
--- a/superset-frontend/src/pages/DatasetList/DatasetList.integration.test.tsx
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.integration.test.tsx
@@ -20,7 +20,7 @@ import { act, screen, waitFor, within } from
'@testing-library/react';
import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import rison from 'rison';
-import { selectOption } from 'spec/helpers/testing-library';
+import { selectPillOption } from 'spec/helpers/testing-library';
import {
setupMocks,
renderDatasetList,
@@ -102,11 +102,11 @@ test('ListView provider correctly merges filter + sort +
pagination state on ref
).toBeGreaterThan(callsBeforeSort);
});
- // 2. Apply a filter using selectOption helper
+ // 2. Apply a filter using selectPillOption helper (compact pill UI)
const beforeFilterCallCount = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
- await selectOption('Virtual', 'Type');
+ await selectPillOption('Virtual', 'Type');
// Wait for filter API call to complete
await waitFor(() => {
diff --git
a/superset-frontend/src/pages/DatasetList/DatasetList.listview.test.tsx
b/superset-frontend/src/pages/DatasetList/DatasetList.listview.test.tsx
index 883658156c9..6ad588a8128 100644
--- a/superset-frontend/src/pages/DatasetList/DatasetList.listview.test.tsx
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.listview.test.tsx
@@ -27,7 +27,7 @@ import userEvent from '@testing-library/user-event';
import fetchMock from 'fetch-mock';
import rison from 'rison';
import { SupersetClient } from '@superset-ui/core';
-import { selectOption } from 'spec/helpers/testing-library';
+import { selectPillOption } from 'spec/helpers/testing-library';
import {
setupMocks,
renderDatasetList,
@@ -1510,11 +1510,8 @@ test('bulk selection clears when filter changes', async
() => {
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
- // Wait for filter combobox to be ready before applying filter
- await screen.findByRole('combobox', { name: 'Type' });
-
- // Apply a filter using selectOption helper
- await selectOption('Virtual', 'Type');
+ // Apply a filter using selectPillOption helper (compact pill UI)
+ await selectPillOption('Virtual', 'Type');
// Wait for filter API call to complete
await waitFor(() => {
@@ -1556,16 +1553,13 @@ test('type filter API call includes correct filter
parameter', async () => {
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
});
- // Wait for Type filter combobox
- await screen.findByRole('combobox', { name: 'Type' });
-
// Snapshot call count before filter
const callsBeforeFilter = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
- // Apply Type filter
- await selectOption('Virtual', 'Type');
+ // Apply Type filter using compact pill UI
+ await selectPillOption('Virtual', 'Type');
// Wait for filter API call to complete
await waitFor(() => {
@@ -1606,16 +1600,13 @@ test('type filter persists after duplicating a
dataset', async () => {
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
});
- // Wait for Type filter combobox
- await screen.findByRole('combobox', { name: 'Type' });
-
// Snapshot call count before filter
const callsBeforeFilter = fetchMock.callHistory.calls(
API_ENDPOINTS.DATASOURCE_COMBINED,
).length;
- // Apply Type filter
- await selectOption('Virtual', 'Type');
+ // Apply Type filter using compact pill UI
+ await selectPillOption('Virtual', 'Type');
// Wait for filter API call to complete
await waitFor(() => {
diff --git a/superset-frontend/src/pages/DatasetList/DatasetList.test.tsx
b/superset-frontend/src/pages/DatasetList/DatasetList.test.tsx
index 2d1c4752e9b..ee4c1257aae 100644
--- a/superset-frontend/src/pages/DatasetList/DatasetList.test.tsx
+++ b/superset-frontend/src/pages/DatasetList/DatasetList.test.tsx
@@ -200,8 +200,8 @@ test('renders Name search filter', async () => {
test('renders Type filter (Virtual/Physical dropdown)', async () => {
renderDatasetList(mockAdminUser);
- // Filter dropdowns should be present
- const filters = await screen.findAllByRole('combobox');
+ // Filter pills should be present (compact pill UI)
+ const filters = await screen.findAllByTestId('compact-filter-pill');
expect(filters.length).toBeGreaterThan(0);
});
@@ -445,7 +445,8 @@ test('selecting Database filter triggers API call with
database relation filter'
await waitForDatasetsPageReady();
- const filtersContainers = screen.getAllByRole('combobox');
+ // Filter pills should be present (compact pill UI replaces comboboxes)
+ const filtersContainers = screen.getAllByTestId('compact-filter-pill');
expect(filtersContainers.length).toBeGreaterThan(0);
});
diff --git a/superset-frontend/src/pages/GroupsList/GroupsList.test.tsx
b/superset-frontend/src/pages/GroupsList/GroupsList.test.tsx
index 785066048e8..81bd86f8078 100644
--- a/superset-frontend/src/pages/GroupsList/GroupsList.test.tsx
+++ b/superset-frontend/src/pages/GroupsList/GroupsList.test.tsx
@@ -121,13 +121,17 @@ describe('GroupsList', () => {
test('renders the filters correctly', async () => {
await renderComponent();
- const filtersSelect = screen.getAllByTestId('filters-select')[0];
- expect(within(filtersSelect).getByText(/name/i)).toBeInTheDocument();
- expect(within(filtersSelect).getByText(/label/i)).toBeInTheDocument();
-
expect(within(filtersSelect).getByText(/description/i)).toBeInTheDocument();
- expect(within(filtersSelect).getByText(/roles/i)).toBeInTheDocument();
- expect(within(filtersSelect).getByText(/users/i)).toBeInTheDocument();
+ // The compact filter UI renders the first search filter as an input,
+ // and select filters as pill buttons. Only "Name" search renders inline;
+ // "Label" and "Description" searches are hidden (one search box per page).
+ expect(screen.getByTestId('filters-search')).toBeInTheDocument();
+
+ // Select filters render as compact pill buttons
+ const pills = screen.getAllByTestId('compact-filter-pill');
+ const pillLabels = pills.map(p => p.textContent ?? '');
+ expect(pillLabels.some(l => /roles/i.test(l))).toBe(true);
+ expect(pillLabels.some(l => /users/i.test(l))).toBe(true);
});
test('renders correct columns in the table', async () => {
diff --git a/superset-frontend/src/pages/RolesList/RolesList.test.tsx
b/superset-frontend/src/pages/RolesList/RolesList.test.tsx
index 39d8069429c..6a179238d4e 100644
--- a/superset-frontend/src/pages/RolesList/RolesList.test.tsx
+++ b/superset-frontend/src/pages/RolesList/RolesList.test.tsx
@@ -151,8 +151,11 @@ describe('RolesList', () => {
test('renders filters options', async () => {
await renderAndWait();
- const typeFilter = screen.queryAllByTestId('filters-select');
- expect(typeFilter).toHaveLength(4);
+ // Compact filter UI: one search input for "Name" and 3 select pills
+ // (Users, Permissions, Groups).
+ expect(screen.getByTestId('filters-search')).toBeInTheDocument();
+ const selectContainers = screen.getAllByTestId('select-filter-container');
+ expect(selectContainers).toHaveLength(3);
});
test('renders correct list columns', async () => {
diff --git
a/superset-frontend/src/pages/RowLevelSecurityList/RowLevelSecurityList.test.tsx
b/superset-frontend/src/pages/RowLevelSecurityList/RowLevelSecurityList.test.tsx
index b23b2421461..538e7ae41b1 100644
---
a/superset-frontend/src/pages/RowLevelSecurityList/RowLevelSecurityList.test.tsx
+++
b/superset-frontend/src/pages/RowLevelSecurityList/RowLevelSecurityList.test.tsx
@@ -166,11 +166,14 @@ describe('RuleList RTL', () => {
test('renders filter options', async () => {
await renderAndWait();
+ // Compact filter UI: only the first search filter renders (Name),
+ // subsequent search filters (Group Key) are hidden — one search box per
page.
const searchFilters = screen.queryAllByTestId('filters-search');
- expect(searchFilters).toHaveLength(2);
+ expect(searchFilters).toHaveLength(1);
- const typeFilter = screen.queryAllByTestId('filters-select');
- expect(typeFilter).toHaveLength(3); // Update to expect 3 select filters
+ // Select filters render as compact pill buttons (Filter Type, Modified by)
+ const selectContainers =
screen.queryAllByTestId('select-filter-container');
+ expect(selectContainers).toHaveLength(2);
});
test('renders correct list columns', async () => {
diff --git a/superset-frontend/src/pages/UsersList/UsersList.test.tsx
b/superset-frontend/src/pages/UsersList/UsersList.test.tsx
index e994bd485cc..63539113eed 100644
--- a/superset-frontend/src/pages/UsersList/UsersList.test.tsx
+++ b/superset-frontend/src/pages/UsersList/UsersList.test.tsx
@@ -138,16 +138,16 @@ describe('UsersList', () => {
test('renders filters options', async () => {
await renderAndWait();
- const submenu = screen.queryAllByTestId('filters-select')[0];
- expect(within(submenu).getByText(/first name/i)).toBeInTheDocument();
- expect(within(submenu).getByText(/last name/i)).toBeInTheDocument();
- expect(within(submenu).getByText(/email/i)).toBeInTheDocument();
- expect(within(submenu).getByText(/username/i)).toBeInTheDocument();
- expect(within(submenu).getByText(/roles/i)).toBeInTheDocument();
- expect(within(submenu).getByText(/is active?/i)).toBeInTheDocument();
- expect(within(submenu).getByText(/created on/i)).toBeInTheDocument();
- expect(within(submenu).getByText(/changed on/i)).toBeInTheDocument();
- expect(within(submenu).getByText(/last login/i)).toBeInTheDocument();
+ // The compact filter UI shows: only the first search filter as an input,
+ // and select/datetime filters as pill buttons. Only "First name" search
+ // renders (subsequent search filters are hidden — one search box per
page).
+ expect(screen.getByTestId('filters-search')).toBeInTheDocument();
+
+ // Select and datetime filters render as compact pill buttons
+ const pills = screen.getAllByTestId('compact-filter-pill');
+ const pillLabels = pills.map(p => p.textContent ?? '');
+ expect(pillLabels.some(l => /roles/i.test(l))).toBe(true);
+ expect(pillLabels.some(l => /is active\?/i.test(l))).toBe(true);
});
test('renders correct list columns', async () => {