Compare commits

...

31 Commits

Author SHA1 Message Date
kasiazjc
94aa03bd3d fix(listview): fix search input clipping and match antd option height
Remove overflow:hidden from PanelContainer so the Input focus ring
is not clipped at the panel boundary.

Set OptionItem line-height to theme.lineHeight and derive the vertical
padding from antd's exact formula (controlHeight - fontSize * lineHeight) / 2,
matching the height of antd Select dropdown options in Explore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:55:46 +00:00
kasiazjc
ade1a0e5e2 fix(listview): close dropdown on resize; add OptionList padding
CompactFilterTrigger: close dropdown on window resize — AntD Dropdown
does not reposition on resize so the panel detaches from the pill.

CompactSelectPanel: add paddingXXS top/bottom to OptionList so the
first/last items have breathing room inside overflow:hidden when there
is no search row (small static selects), preventing outline/background
from being clipped against the panel edge.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:52:04 +00:00
kasiazjc
a704330400 docs: retake screenshots — tooltip, dropdown, active state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:50:58 +00:00
kasiazjc
6c49ad74f9 fix(listview): remove duplicate top padding on panel container
PanelContainer paddingXXS (4px) top + SearchRow sizeUnit*2 (8px) top
= 12px gap. Remove PanelContainer top padding so SearchRow controls
the top spacing exclusively.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:45:10 +00:00
kasiazjc
345d87b4b0 docs: update compact filter pills screenshots
New screenshots show: always-visible Clear all button (disabled when inactive),
Explore-style dropdown options, search input contained within panel, and
edge-to-edge option rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:44:30 +00:00
kasiazjc
d013003cf1 fix(listview): match search top padding to left/right for consistency
SearchRow top padding was 0 while left/right was sizeUnit*2 (8px).
Make them equal so the search input has uniform breathing room.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:44:03 +00:00
kasiazjc
75d731398e Revert "refactor(listview): CompactSelectPanel reuses Select/AsyncSelect"
This reverts commit 6a16e7dca4.
2026-05-20 14:42:05 +00:00
kasiazjc
6a16e7dca4 refactor(listview): CompactSelectPanel reuses Select/AsyncSelect
Replace custom option rendering, debounced search, race-condition
guards, and loading states with Select/AsyncSelect from
@superset-ui/core/components. The trigger is hidden via a
zero-height wrapper; the dropdown renders inside a container div
via getPopupContainer, giving the same visual as Explore's select
dropdowns. Also exposes the 'open' prop in AntdExposedProps so
Select/AsyncSelect can be controlled externally.

Removes ~130 lines of reimplemented debounce/race-condition/
filter logic and eliminates the outline hover hack.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:41:18 +00:00
kasiazjc
eb945f8289 fix(listview): search input contained within dropdown panel
Wrap Input in SearchRow with horizontal padding instead of margins on
.ant-input-affix-wrapper. width:100% + margin overflowed the container;
padding on a wrapper div keeps the input fully inside. Restore
overflow:hidden (needed for borderRadiusLG clipping) — it no longer
clips the input since the wrapper contains it correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:38:30 +00:00
kasiazjc
96234d2cfe fix(listview): match Explore Select dropdown height + fix clear-all after refresh
CompactSelectPanel: remove overflow:hidden from panel (was clipping search
input); remove min-height from OptionItem (5px padding alone gives the
correct 24px height matching AntD Select optionPadding, not 42px).

index.tsx: clearFilters/clearFilterById now call updateFilterValue directly
as a safety net so URL always updates even when filter refs are stale
(e.g. after page refresh where internalFilters is URL-hydrated).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:35:16 +00:00
kasiazjc
541cfd989c style(listview): match Select dropdown option style from Explore
Use exact visual pattern from @superset-ui/core Select:
- borderRadiusLG on container (was borderRadius)
- paddingXXS top/bottom on container (matches ant-select-dropdown)
- borderRadiusSM on each OptionItem (rounded highlight, not edge-to-edge)
- 5px vertical padding on items (matches optionPadding token default)
- outline: 2px solid colorPrimary on hover (Superset's own Select override)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:16:06 +00:00
kasiazjc
3c1f1d5535 feat(listview): tooltip on disabled Clear all button
Show 'No filters applied' tooltip on hover when the Clear all button
is disabled, so users understand why it is not interactive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:11:24 +00:00
kasiazjc
97c22497f4 fix(listview): search input grows to fill available space
Replace fixed 176px width on the search filter container with flex:1
so it expands into the space not taken by the pill filters.
min-width: 160px prevents it collapsing on narrow screens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:10:11 +00:00
kasiazjc
f28a8f6f78 fix(listview): Clear all always visible, disabled when no active filters
Match Superset dashboard FilterBar pattern (isClearAllEnabled): always
render the button so users can discover it, just disable it when nothing
is selected. Prevents layout shift and builds muscle memory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:06:53 +00:00
kasiazjc
b868d3c7bf feat(listview): add Clear all button to compact filter controls
Show a 'Clear all' text button at the end of the filter pill row when
one or more filters are active. Disappears when all filters are inactive.
Matches dashboard native filter bar behaviour.

Also update ListView tests to use new compact-filter-pill buttons
instead of the old combobox queries.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 14:06:23 +00:00
kasiazjc
765d9d39a9 docs: add compact filter pills screenshots for PR
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:48:11 +00:00
kasiazjc
d520d461b3 fix(listview): read tooltip label from DOM textContent, not ReactNode
Owner options have a styled ReactNode label — typeof checks fail and we
fell back to String(value) = "1". Instead, capture the rendered text via
e.currentTarget.textContent at click/keydown time (always the visible
string regardless of label type) and use it as the normalized label.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:46:36 +00:00
kasiazjc
926b9b2311 feat(listview): compact filter pills for all CRUD list views
Replace full-width inline filter blocks with compact pill triggers across
all ListView CRUD views. Each non-search filter becomes a pill that opens
a focused dropdown on click. Async selects use a debounced options panel
matching the DrillBy submenu pattern. Date/range filters get an Apply
button. Active pills show colored border, dot indicator, hover tooltip
with selected value, and inline clear button.

- CompactFilterTrigger: pill + Ant Design Dropdown, injects isOpen/onClose
  into panel children via cloneElement; Tooltip on pill for active state;
  destroyPopupOnHide intentionally omitted to preserve filter refs
- CompactSelectPanel: options list with debounced async fetch, race-condition
  guard, keyboard nav (Enter/Space), proper ARIA (listbox/option/aria-selected,
  aria-label on listbox)
- FilterPopoverContent: Apply button wrapper for DateRange/NumericalRange;
  inner label visually hidden but screen-reader accessible
- index: expose clearFilterById in ref type to match ListView contract
- Tests: CompactSelectPanel.test.tsx and FilterPopoverContent.test.tsx added;
  datetime_range and numerical_range pill tests added to index.test.tsx;
  tooltip test in CompactFilterTrigger.test.tsx made non-brittle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-20 13:35:23 +00:00
kasiazjc
934443e09f fix(listview): cache tooltip label in UIFilters state to survive URL round-trip
URL serialization via use-query-params strips the label from the selected
SelectOption, leaving only {value: 1}. Reading the label back from
internalFilters after hydration therefore gives undefined.

Cache the string label in tooltipLabels state at the moment of selection
(before URL round-trip). Clear on onClear, clearFilters, and clearFilterById.
tooltipTitle now reads from the cache instead of internalFilters.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:55:37 +00:00
kasiazjc
7d5b0e35e2 fix(listview): move Tooltip inside Dropdown so it fires on pill hover
Wrapping the Dropdown in an outer span+Tooltip caused Dropdown to absorb
mouse events before they reached the Tooltip. Move Tooltip to wrap
FilterPill directly inside the Dropdown — Ant Design supports Tooltip
as a Dropdown child and the pill receives hover events cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:48:30 +00:00
kasiazjc
6224bc7aec fix(listview): normalize select option + match AntD dropdown styles
Normalize selected option to plain {label: string, value} before passing
to onSelect — matches original SelectFilter behaviour and prevents
'Converting circular structure to JSON' when emotion-styled ReactNode
labels are serialized to URL query params.

Style OptionItem to match AntD Select dropdown exactly: colorBgElevated
container, fontSize (not fontSizeSM), colorText always (not colorPrimary
on selected), colorFillTertiary hover, borderRadiusSM on items, 32px
height with sizeUnit padding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:42:05 +00:00
kasiazjc
1b5a31a203 fix(listview): handle async fetchSelects rejection and null data
Add .catch() to the fetch effect so rejected promises (network errors,
auth failures) don't surface as unhandled rejections. Guard result.data
with ?? [] so null/undefined responses fall back to empty list. Filter
null entries from displayOptions before rendering.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:54:58 +00:00
kasiazjc
7812f64278 fix(listview): remove ViewModeContainer margin-top that offset grid/list icons
The 21px margin-top on ViewModeContainer existed to visually align with
filter label text above each input. Labels are now hidden (pills have no
label), so the offset pushed the icons below the pill midline. Removing
it lets align-items: center on .header do the right thing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:46:43 +00:00
kasiazjc
12621e3e97 fix(listview): centre filter row with view-mode toggle icons
Add align-items: center to .header so the grid/list toggle and the
filter pills sit on the same midline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 15:42:51 +00:00
kasiazjc
81adabf667 refactor(listview): remove dead destructured props from compact filter index
optionFilterProps, paginate, and popupStyle were destructured in the filter
map but are no longer used after SelectFilter was replaced with
CompactSelectPanel. Remove them to eliminate dead code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 12:45:35 +00:00
kasiazjc
bff26e9256 refactor(listview): compact filter review fixes — type safety, a11y, race conditions
- index.tsx: remove redundant filterValue variable (merged into initialValue),
  remove no-op onClose={() => {}} from CompactSelectPanel (it's injected via
  cloneElement, the explicit pass was misleading), fix `any` type in
  clearFilters forEach, pass popupType="dialog" for datetime_range and
  numerical_range triggers
- CompactFilterTrigger: replace as Record<string,unknown> cloneElement cast
  with typed FilterPanelInjectedProps interface; add popupType prop (default
  'listbox', 'dialog' for form panels) wired to aria-haspopup so screen readers
  get the correct popup role per ARIA spec
- CompactSelectPanel: add cancelled flag to fetchSelects effect to prevent
  stale-closure state updates when deps change mid-flight; add
  debouncedSetSearch.cancel() cleanup on unmount; add role="listbox" to
  OptionList and role="option" + aria-selected + tabIndex + onKeyDown(Enter/
  Space) to OptionItem for keyboard navigation
- FilterPopoverContent: replace label { display: none } with visually-hidden
  CSS (position:absolute + clip) so FormLabel remains accessible to screen
  readers (datepicker/number inputs retain their accessible name context)
  while still hidden visually
- Tests: add 6 new tests covering isOpen/onClose injection via cloneElement,
  aria-haspopup default and dialog values, dropdown close aria-expanded reset,
  select filter pill rendering, and active state when value is present

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 12:44:23 +00:00
kasiazjc
8fa074d3a1 fix(listview): pin search filter container height to controlHeight
Force search-filter-container to controlHeight so it aligns flush with
the pill buttons (same height, align-items: center on the controls row
then centers everything correctly).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 12:37:34 +00:00
kasiazjc
f984153936 fix(listview): compact filters UX audit — focus, date apply, alignment
Fix autoFocus stealing focus from search: inject isOpen via cloneElement,
use InputRef.input.focus pattern from DrillBySubmenu (100ms delay, preventScroll).
Debounce remote fetch triggers with Constants.FAST_DEBOUNCE. Reset panel
search on close.

Add FilterPopoverContent wrapper with Apply button for date/numerical range
filters so cloneElement-injected onClose closes the dropdown on submit.
Hides inner FormLabel via CSS since the pill already shows the label.

Match DrillBySubmenu styling: iconSize="l"/colorIcon for search prefix,
colorTextDisabled for empty state, min-height: 35px for option rows,
container padding replaces SearchRow border, .ant-input-affix-wrapper
margin-bottom for visual separation, min-width: 220px, box-shadow: none
on the input.

Pass external loading prop to CompactSelectPanel. Change .controls to
align-items: center and hide search-filter-container label via CSS.
Add aria-label to pill when label is a string.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 09:09:02 +00:00
kasiazjc
c09295aa52 fix(listview): clear filter — keep dropdown mounted, direct state reset
Remove destroyPopupOnHide so CompactSelectPanel stays mounted when the
dropdown closes, keeping its ref live. Add updateFilterValue(index, undefined)
directly in all onClear handlers as a safety net so the ListView filter
state always resets even if the ref is stale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 08:52:29 +00:00
kasiazjc
002cb30a44 refine(listview): compact filters — match border radius/height, dropdown panel, hover tooltips
Use theme.borderRadius and theme.controlHeight on filter pills to match
the Search input and Select controls on the same row. Replace the
Popover+SelectFilter approach with a new CompactSelectPanel that renders a
clean options list (search + checkmark) opened via Dropdown — no
intermediate widget. Add tooltipTitle prop to CompactFilterTrigger so
hovering a filled pill shows the selected value. Align .controls with
flex-end so pills sit flush with the bottom of the search input.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 08:50:42 +00:00
kasiazjc
6540353960 feat(listview): compact filter pills with popover for CRUD views
Replace full-width inline filter blocks with compact pill triggers that
open a Popover on click. Search filter stays inline. Adds active state
indicator (dot + border + weight) and inline clear button. Single new
component, no new deps. Controls gap tightened to 10px for pill rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 08:29:59 +00:00
22 changed files with 1451 additions and 55 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

View File

@@ -0,0 +1,125 @@
/**
* 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';
const defaultProps = {
label: 'Owner',
hasValue: false,
onClear: jest.fn(),
children: <div data-testid="filter-content">Filter content</div>,
};
beforeEach(() => {
jest.clearAllMocks();
});
test('renders the label', () => {
render(<CompactFilterTrigger {...defaultProps} />);
expect(screen.getByText('Owner')).toBeInTheDocument();
});
test('renders as inactive pill with down chevron when hasValue is false', () => {
render(<CompactFilterTrigger {...defaultProps} />);
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toBeInTheDocument();
});
test('renders active state with close icon when hasValue is true', () => {
render(<CompactFilterTrigger {...defaultProps} hasValue />);
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
});
test('toggles aria-expanded when pill is clicked', async () => {
render(<CompactFilterTrigger {...defaultProps} />);
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();
render(<CompactFilterTrigger {...defaultProps} hasValue onClear={onClear} />);
const closeIcon = screen.getByRole('button', { name: /close/i });
await userEvent.click(closeIcon);
expect(onClear).toHaveBeenCalledTimes(1);
});
test('does not render tooltip wrapper when tooltipTitle is absent', () => {
const { container } = render(<CompactFilterTrigger {...defaultProps} />);
// No ant-tooltip-open class expected when no tooltip
expect(container.querySelector('.ant-tooltip')).not.toBeInTheDocument();
});
test('shows active state indicators when hasValue and tooltipTitle are set', () => {
render(
<CompactFilterTrigger
{...defaultProps}
hasValue
tooltipTitle="Some Owner"
/>,
);
// Active pill: close icon visible, aria-expanded starts closed
expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument();
expect(screen.getByTestId('compact-filter-pill')).toHaveAttribute(
'aria-expanded',
'false',
);
});
test('injects isOpen and onClose props into child element when dropdown opens', async () => {
const RecordingChild = jest.fn(() => (
<div data-testid="recording-child">content</div>
));
render(
<CompactFilterTrigger {...defaultProps}>
<RecordingChild />
</CompactFilterTrigger>,
);
const pill = screen.getByTestId('compact-filter-pill');
await userEvent.click(pill);
// After opening, the child should receive isOpen=true and an onClose function
expect(RecordingChild).toHaveBeenLastCalledWith(
expect.objectContaining({ isOpen: true, onClose: expect.any(Function) }),
expect.anything(),
);
});
test('sets aria-haspopup to listbox by default', () => {
render(<CompactFilterTrigger {...defaultProps} />);
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toHaveAttribute('aria-haspopup', 'listbox');
});
test('sets aria-haspopup to dialog when popupType is dialog', () => {
render(<CompactFilterTrigger {...defaultProps} popupType="dialog" />);
const pill = screen.getByTestId('compact-filter-pill');
expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
});
test('closing dropdown resets aria-expanded to false', async () => {
render(<CompactFilterTrigger {...defaultProps} />);
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,163 @@
/**
* 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 React, { useEffect, useState, type ReactNode, type MouseEvent } from 'react';
import { useTheme, styled, css } from '@apache-superset/core/theme';
import { Dropdown, Tooltip, Icons } from '@superset-ui/core/components';
type FilterPanelInjectedProps = {
onClose?: () => void;
isOpen?: boolean;
};
interface CompactFilterTriggerProps {
label: ReactNode;
hasValue: boolean;
onClear: () => void;
children: 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;
transition:
border-color 0.2s,
background 0.2s,
color 0.2s;
&: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 theme = useTheme();
// 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);
};
const pill = (
<Tooltip
title={hasValue && !open ? tooltipTitle : undefined}
mouseEnterDelay={0.5}
>
<FilterPill
$active={hasValue}
type="button"
data-test="compact-filter-pill"
aria-haspopup={popupType}
aria-expanded={open}
aria-label={typeof label === 'string' ? label : undefined}
>
{hasValue && <ActiveDot />}
<span>{label}</span>
{hasValue ? (
<Icons.CloseOutlined
iconSize="xs"
iconColor={theme.colorPrimary}
onClick={handleClear}
/>
) : (
<Icons.DownOutlined
iconSize="xs"
iconColor={theme.colorTextSecondary}
/>
)}
</FilterPill>
</Tooltip>
);
return (
// destroyPopupOnHide intentionally omitted: keeping the popup mounted
// preserves filter component refs so external clearFilters() calls can
// reach the panel instance after it has been opened at least once.
<Dropdown
open={open}
onOpenChange={setOpen}
trigger={['click']}
popupRender={() =>
React.isValidElement(children)
? React.cloneElement(
children as React.ReactElement<FilterPanelInjectedProps>,
{
onClose: () => setOpen(false),
isOpen: open,
},
)
: (children as React.ReactElement)
}
placement="bottomLeft"
>
{pill}
</Dropdown>
);
}

View File

@@ -0,0 +1,329 @@
/**
* 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>();
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);
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, 50);
});
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,312 @@
/**
* 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 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';
interface CompactSelectPanelProps {
selects?: Filter['selects'];
fetchSelects?: Filter['fetchSelects'];
value?: SelectOption;
onSelect: (option: SelectOption | undefined, isClear?: boolean) => void;
onClose?: () => void;
/** Injected by CompactFilterTrigger via cloneElement — true when dropdown is open */
isOpen?: boolean;
/** 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,
}: 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 [selectedOption, setSelectedOption] = useState<
SelectOption | undefined
>(value);
const isLoading = externalLoading || internalLoading;
const debouncedSetSearch = useMemo(
() => debounce(setDebouncedSearch, Constants.FAST_DEBOUNCE),
[],
);
useEffect(
() => () => {
debouncedSetSearch.cancel();
},
[debouncedSetSearch],
);
// Sync selected state when external value changes (e.g. clearFilters called from parent)
useEffect(() => {
setSelectedOption(value);
}, [value]);
// 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, 50)
.then(result => {
if (!cancelled) setRemoteOptions(result?.data ?? []);
})
.catch(() => {
if (!cancelled) setRemoteOptions([]);
})
.finally(() => {
if (!cancelled) setInternalLoading(false);
});
return () => {
cancelled = true;
};
}, [debouncedSearch, fetchSelects]);
useImperativeHandle(ref, () => ({
clearFilter: () => {
setSelectedOption(undefined);
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);
// Show search for async selects or large static lists
const showSearch = !!fetchSelects || selects.length > 6;
// displayText is the actual rendered text of the clicked list item, captured
// from the DOM via e.currentTarget.textContent. This is more reliable than
// reading opt.label, which may be a styled ReactNode (e.g. for owner options)
// rather than a plain string — causing tooltip to show the raw value instead.
const handleSelect = (opt: SelectOption, displayText?: string) => {
const isDeselect = selectedOption?.value === opt.value;
// Normalize to a plain object so the value can be safely serialized to
// URL query params without circular-reference errors from emotion metadata
// on styled ReactNode labels.
const next = isDeselect
? undefined
: {
label:
displayText ||
(typeof opt.label === 'string'
? opt.label
: String(opt.value ?? '')),
value: opt.value,
};
setSelectedOption(next);
onSelect(next, isDeselect);
onClose?.();
};
return (
<PanelContainer>
{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 => {
const isActive = selectedOption?.value === opt.value;
const getDisplayText = (el: HTMLElement) =>
el.textContent?.trim() || undefined;
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));
}
}}
>
<OptionLabel>{opt.label}</OptionLabel>
{isActive && (
<Icons.CheckOutlined
iconSize="s"
iconColor={theme.colorPrimary}
/>
)}
</OptionItem>
);
})
)}
</OptionList>
</PanelContainer>
);
}
export default forwardRef(CompactSelectPanel);

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;
/** Injected by CompactFilterTrigger via cloneElement */
onClose?: () => void;
/** Injected by CompactFilterTrigger via cloneElement — unused but accepted to avoid React unknown-prop warnings */
isOpen?: boolean;
}
const Wrapper = styled.div`
${({ theme }) => css`
padding: ${theme.sizeUnit * 2}px;
display: flex;
flex-direction: column;
gap: ${theme.sizeUnit * 2}px;
/* 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

@@ -97,6 +97,169 @@ test('search filter passes autoComplete prop correctly', () => {
expect(input.autocomplete).toBe('new-password');
});
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 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}
/>,
);
// When a value is present, the clear (close) icon should be shown
expect(screen.getByRole('button', { name: /close/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 value 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: ['2024-01-01', '2024-12-31'],
},
]}
updateFilterValue={mockUpdateFilterValue}
/>,
);
expect(screen.getByRole('button', { name: /close/i })).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: /close/i })).toBeInTheDocument();
});
test('renders multiple search filters with different inputName values', () => {
const filters = [
{

View File

@@ -21,6 +21,7 @@ import {
forwardRef,
useImperativeHandle,
useMemo,
useState,
RefObject,
} from 'react';
@@ -34,9 +35,11 @@ import type {
} from '../types';
import type { FilterHandler } from './types';
import SearchFilter from './Search';
import SelectFilter from './Select';
import DateRangeFilter from './DateRange';
import NumericalRangeFilter from './NumericalRange';
import CompactFilterTrigger from './CompactFilterTrigger';
import CompactSelectPanel from './CompactSelectPanel';
import FilterPopoverContent from './FilterPopoverContent';
interface UIFiltersProps {
filters: Filters;
@@ -46,7 +49,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,16 +60,32 @@ 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>>(
{},
);
useImperativeHandle(ref, () => ({
clearFilters: () => {
filterRefs.forEach((filter: any) => {
filterRefs.forEach((filter, index) => {
filter.current?.clearFilter?.();
// Direct reset as safety net — ensures URL updates even if the ref
// is stale (e.g. filter value was hydrated from URL after page refresh).
updateFilterValue(index, undefined);
});
setTooltipLabels({});
},
clearFilterById: (id: string) => {
const index = filters.findIndex(f => f.id === id);
if (index >= 0) {
filterRefs[index]?.current?.clearFilter?.();
updateFilterValue(index, undefined);
setTooltipLabels(prev => {
const next = { ...prev };
delete next[index];
return next;
});
}
},
}));
@@ -78,8 +100,6 @@ function UIFilters(
key,
id,
input,
optionFilterProps,
paginate,
selects,
toolTipDescription,
onFilterUpdate,
@@ -87,7 +107,6 @@ function UIFilters(
dateFilterValueType,
min,
max,
popupStyle,
autoComplete,
inputName,
},
@@ -95,33 +114,54 @@ function UIFilters(
) => {
const initialValue = internalFilters?.[index]?.value;
if (input === 'select') {
const selectValue = initialValue as SelectOption | undefined;
// Use cached label — URL round-trip strips the label from internalFilters,
// leaving only the value (e.g. {value: 1} with no label field).
const tooltipTitle = !!selectValue
? tooltipLabels[index]
: undefined;
return (
<SelectFilter
ref={filterRefs[index]}
Header={Header}
fetchSelects={fetchSelects}
initialValue={initialValue}
<CompactFilterTrigger
key={key}
name={id}
onSelect={(
option: SelectOption | undefined,
isClear?: boolean,
) => {
if (onFilterUpdate) {
// Filter change triggers both onChange AND onClear, only want to track onChange
if (!isClear) {
label={Header}
hasValue={!!selectValue}
tooltipTitle={tooltipTitle}
onClear={() => {
filterRefs[index]?.current?.clearFilter?.();
updateFilterValue(index, undefined);
setTooltipLabels(prev => {
const next = { ...prev };
delete next[index];
return next;
});
}}
>
<CompactSelectPanel
ref={filterRefs[index]}
selects={selects}
fetchSelects={fetchSelects}
value={initialValue as SelectOption | undefined}
loading={loading ?? false}
onSelect={(
option: SelectOption | undefined,
isClear?: boolean,
) => {
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}
/>
updateFilterValue(index, option);
}}
/>
</CompactFilterTrigger>
);
}
if (input === 'search' && typeof Header === 'string') {
@@ -145,30 +185,71 @@ function UIFilters(
);
}
if (input === 'datetime_range') {
const hasDateValue =
Array.isArray(initialValue) && initialValue.some(Boolean);
const dateTooltip = hasDateValue
? (initialValue as (string | number)[])
.filter(Boolean)
.join(' ')
: undefined;
return (
<DateRangeFilter
ref={filterRefs[index]}
Header={Header}
initialValue={initialValue}
<CompactFilterTrigger
key={key}
name={id}
onSubmit={value => updateFilterValue(index, value)}
dateFilterValueType={dateFilterValueType || 'unix'}
/>
label={Header}
hasValue={hasDateValue}
tooltipTitle={dateTooltip}
popupType="dialog"
onClear={() => {
filterRefs[index]?.current?.clearFilter?.();
updateFilterValue(index, undefined);
}}
>
<FilterPopoverContent>
<DateRangeFilter
ref={filterRefs[index]}
Header={Header}
initialValue={initialValue}
name={id}
onSubmit={value => updateFilterValue(index, value)}
dateFilterValueType={dateFilterValueType || 'unix'}
/>
</FilterPopoverContent>
</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 (
<NumericalRangeFilter
ref={filterRefs[index]}
Header={Header}
initialValue={initialValue}
min={min}
max={max}
<CompactFilterTrigger
key={key}
name={id}
onSubmit={value => updateFilterValue(index, value)}
/>
label={Header}
hasValue={hasRangeValue}
tooltipTitle={rangeTooltip}
popupType="dialog"
onClear={() => {
filterRefs[index]?.current?.clearFilter?.();
updateFilterValue(index, undefined);
}}
>
<FilterPopoverContent>
<NumericalRangeFilter
ref={filterRefs[index]}
Header={Header}
initialValue={initialValue}
min={min}
max={max}
name={id}
onSubmit={value => updateFilterValue(index, value)}
/>
</FilterPopoverContent>
</CompactFilterTrigger>
);
}
return 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

View File

@@ -65,13 +65,31 @@ 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;
[data-test='search-filter-container'] {
flex: 1;
min-width: ${theme.sizeUnit * 40}px;
width: auto;
height: ${theme.controlHeight}px;
justify-content: center;
label {
display: none;
}
.ant-input-affix-wrapper {
width: 100%;
}
}
}
}
@@ -167,7 +185,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 +209,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 +396,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.
@@ -414,6 +462,23 @@ export function ListView<T extends object = any>({
updateFilterValue={applyFilterValue}
/>
)}
{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>
)}
{viewMode === 'card' && cardSortSelectOptions && (
<CardSortSelect
initialSort={sortBy}