feat(listview): compact filter pills with popover for CRUD views (#40169)

This commit is contained in:
Kasia
2026-05-30 10:30:40 +02:00
committed by GitHub
parent 2d6e68b5f2
commit 4d95a8d034
30 changed files with 2649 additions and 740 deletions

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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(

View File

@@ -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 }]);
});

View File

@@ -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>
);
};

View File

@@ -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');
});

View File

@@ -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>
);
}

View File

@@ -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',
);
});

View File

@@ -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);

View File

@@ -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);

View File

@@ -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');
});

View File

@@ -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>
);
}

View File

@@ -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>john@example.com</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>jane@example.com</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();
});
});

View File

@@ -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);

View File

@@ -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);
});

View File

@@ -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);

View File

@@ -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');
});

View File

@@ -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') {
// 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
}
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);
}}
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'}
/>
);
}
if (input === 'numerical_range') {
return (
}
}}
/>
)}
</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,
)}
</>
);

View File

@@ -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');

View File

@@ -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' : ''} `}>

View File

@@ -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;

View File

@@ -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(() => {

View File

@@ -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();

View File

@@ -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(() => {

View File

@@ -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(() => {

View File

@@ -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);
});

View File

@@ -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 () => {

View File

@@ -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 () => {

View File

@@ -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 () => {

View File

@@ -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 () => {