From 795a54d876852d76299b0eb6f01aa6fd3a239f80 Mon Sep 17 00:00:00 2001 From: Mike Shi Date: Sat, 30 May 2026 14:40:04 -0700 Subject: [PATCH 1/5] feat: render active filters as inline chips in search where input Replaces the separate ActiveFilterPills row below the search input with inline filter chips rendered directly inside SearchWhereInput. The chips slot is wired through both SQLInlineEditor (CodeMirror) and AutocompleteInput (Lucene), and Backspace at cursor position 0 removes the last chip. - Add InlineFilterChips component and filterPillUtils helpers - Add filterChips + onRemoveLastChip props to SQLInlineEditor, AutocompleteInput, and SearchInputV2 - Wire SearchWhereInput to flatten searchFilters into chips and handle last-chip removal - Remove ActiveFilterPills component and its test - Add unit tests for InlineFilterChips and filterPillUtils, plus an E2E suite (filter-chips.spec.ts) --- packages/app/src/DBSearchPage.tsx | 3 +- .../app/src/components/ActiveFilterPills.tsx | 270 --------- .../SQLEditor/SQLInlineEditor.module.scss | 32 +- .../components/SQLEditor/SQLInlineEditor.tsx | 21 + .../SearchInput/AutocompleteInput.module.scss | 46 +- .../SearchInput/AutocompleteInput.tsx | 219 ++++---- .../SearchInput/InlineFilterChips.module.scss | 64 +++ .../SearchInput/InlineFilterChips.tsx | 95 ++++ .../components/SearchInput/SearchInputV2.tsx | 6 + .../SearchInput/SearchWhereInput.module.scss | 24 +- .../SearchInput/SearchWhereInput.tsx | 43 ++ .../__tests__/InlineFilterChips.test.tsx | 203 +++++++ .../__tests__/ActiveFilterPills.test.tsx | 278 ---------- .../__tests__/filterPillUtils.test.ts | 184 +++++++ .../app/src/components/filterPillUtils.ts | 56 ++ .../e2e/components/FilterChipsComponent.ts | 98 ++++ .../e2e/features/search/filter-chips.spec.ts | 511 ++++++++++++++++++ .../app/tests/e2e/page-objects/SearchPage.ts | 3 + 18 files changed, 1474 insertions(+), 682 deletions(-) delete mode 100644 packages/app/src/components/ActiveFilterPills.tsx create mode 100644 packages/app/src/components/SearchInput/InlineFilterChips.module.scss create mode 100644 packages/app/src/components/SearchInput/InlineFilterChips.tsx create mode 100644 packages/app/src/components/SearchInput/__tests__/InlineFilterChips.test.tsx delete mode 100644 packages/app/src/components/__tests__/ActiveFilterPills.test.tsx create mode 100644 packages/app/src/components/__tests__/filterPillUtils.test.ts create mode 100644 packages/app/src/components/filterPillUtils.ts create mode 100644 packages/app/tests/e2e/components/FilterChipsComponent.ts create mode 100644 packages/app/tests/e2e/features/search/filter-chips.spec.ts diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 2862d3b857..b7d201a18c 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -82,7 +82,6 @@ import { useIsFetching } from '@tanstack/react-query'; import { SortingState } from '@tanstack/react-table'; import CodeMirror from '@uiw/react-codemirror'; -import { ActiveFilterPills } from '@/components/ActiveFilterPills'; import { AlertStatusIcon } from '@/components/AlertStatusIcon'; import { ContactSupportText } from '@/components/ContactSupportText'; import { DBSearchPageFilters } from '@/components/DBSearchPageFilters'; @@ -2019,6 +2018,7 @@ export function DBSearchPage() { dateRange={searchedTimeRange} sourceId={inputSource} size="xs" + searchFilters={searchFilters} /> - {searchedConfig != null && searchedSource != null && ( void; -}) { - const isExcluded = pill.type === 'excluded'; - const operator = isExcluded ? ' != ' : pill.type === 'range' ? ': ' : ' = '; - - const tooltipLabel = isInvalid - ? (invalidReason ?? - `Filter not applied: "${pill.field}" isn't a column on the current source. It will reapply if you switch back.`) - : `${pill.field}${operator}${pill.value}`; - - const showDangerAccent = isExcluded && !isInvalid; - - return ( - - - - {pill.field} - - - {operator} - - - {pill.value} - - - - - - - ); -} - -export const ActiveFilterPills = memo(function ActiveFilterPills({ - searchFilters, - invalidFields, - invalidFieldReason, - ...flexProps -}: { - searchFilters: FilterStateHook; - /** - * Field names whose filters are present in state but not applied to the - * current query (e.g. column doesn't exist on the active source). These - * render in a muted, strikethrough, dashed-border style and are preserved - * so the user can switch back without losing their selection. - */ - invalidFields?: Set; - /** - * Optional tooltip override for invalid pills. Receives the field name and - * returns the tooltip text. - */ - invalidFieldReason?: (field: string) => string; -} & FlexProps) { - const { filters, setFilterValue, clearFilter, clearAllFilters } = - searchFilters; - - const pills = useMemo(() => flattenFilters(filters), [filters]); - const [expanded, setExpanded] = useState(false); - const [confirmClear, setConfirmClear] = useState(false); - const confirmTimerRef = useRef>(undefined); - - useEffect(() => { - return () => clearTimeout(confirmTimerRef.current); - }, []); - - const handleRemove = useCallback( - (pill: PillItem) => { - if (pill.type === 'range') { - clearFilter(pill.field); - } else { - setFilterValue( - pill.field, - pill.rawValue!, - pill.type === 'excluded' ? 'exclude' : undefined, - ); - } - }, - [setFilterValue, clearFilter], - ); - - const handleClearAll = useCallback(() => { - if (!confirmClear) { - setConfirmClear(true); - clearTimeout(confirmTimerRef.current); - confirmTimerRef.current = setTimeout(() => setConfirmClear(false), 2000); - return; - } - clearAllFilters(); - setConfirmClear(false); - clearTimeout(confirmTimerRef.current); - }, [confirmClear, clearAllFilters]); - - if (pills.length === 0) { - return null; - } - - const visiblePills = expanded ? pills : pills.slice(0, MAX_VISIBLE_PILLS); - const hiddenCount = pills.length - MAX_VISIBLE_PILLS; - - return ( - - {visiblePills.map((pill, i) => { - const isInvalid = invalidFields?.has(pill.field) ?? false; - return ( - handleRemove(pill)} - /> - ); - })} - {!expanded && hiddenCount > 0 && ( - setExpanded(true)} - > - +{hiddenCount} more - - )} - {expanded && hiddenCount > 0 && ( - setExpanded(false)} - > - Show less - - )} - {pills.length >= 2 && ( - setConfirmClear(false)} - > - {confirmClear ? 'Confirm clear all?' : 'Clear all'} - - )} - - ); -}); diff --git a/packages/app/src/components/SQLEditor/SQLInlineEditor.module.scss b/packages/app/src/components/SQLEditor/SQLInlineEditor.module.scss index 91d7b01863..a039c8deee 100644 --- a/packages/app/src/components/SQLEditor/SQLInlineEditor.module.scss +++ b/packages/app/src/components/SQLEditor/SQLInlineEditor.module.scss @@ -14,9 +14,13 @@ background-color: var(--color-bg-field); border: 1px solid var(--color-border); display: flex; - align-items: flex-start; + flex-wrap: wrap; + align-items: center; min-height: var(--editor-base-height, 36px); box-shadow: none; + gap: 2px; + padding-top: 2px; + padding-bottom: 2px; &.error { border-color: var(--color-bg-danger); @@ -34,31 +38,31 @@ &:not(.expanded) { position: relative; - max-height: var(--editor-base-height, 36px); overflow: hidden; } + + /* When no chips, keep original single-line height cap */ + &:not(.expanded):not([data-has-chips]) { + max-height: var(--editor-base-height, 36px); + } } .label { white-space: nowrap; - - &.sizeXs { - padding-top: 6px; - } - - &:not(.sizeXs) { - padding-top: 8px; - } + flex-shrink: 0; } .cmWrapper { - min-width: 10px; - width: 100%; - padding-top: 3.4px; + min-width: 100px; + flex: 1 1 auto; font-size: var(--mantine-font-size-sm); + /* When expanded with chips, require enough space or wrap to next line */ + .expanded[data-has-chips] > & { + min-width: min(100%, 300px); + } + &.sizeXs { - padding-top: 2.4px; font-size: var(--mantine-font-size-xs); } diff --git a/packages/app/src/components/SQLEditor/SQLInlineEditor.tsx b/packages/app/src/components/SQLEditor/SQLInlineEditor.tsx index 8d7829e3e1..56cc9be74f 100644 --- a/packages/app/src/components/SQLEditor/SQLInlineEditor.tsx +++ b/packages/app/src/components/SQLEditor/SQLInlineEditor.tsx @@ -65,6 +65,9 @@ type SQLInlineEditorProps = { allowMultiline?: boolean; dateRange?: [Date, Date]; sourceId?: string; + filterChips?: React.ReactNode; + /** Returns true if a chip was actually removed so the host can consume the keystroke. */ + onRemoveLastChip?: () => boolean | void; }; const MAX_EDITOR_HEIGHT = '150px'; @@ -91,6 +94,8 @@ export default function SQLInlineEditor({ allowMultiline = true, dateRange, sourceId, + filterChips, + onRemoveLastChip, }: SQLInlineEditorProps & TableConnectionChoice) { const { colorScheme } = useMantineColorScheme(); const _tableConnections = tableConnection @@ -152,6 +157,10 @@ export default function SQLInlineEditor({ const ref = useRef(null); const compartmentRef = useRef(new Compartment()); + const onRemoveLastChipRef = useRef(onRemoveLastChip); + useEffect(() => { + onRemoveLastChipRef.current = onRemoveLastChip; + }, [onRemoveLastChip]); const hasNonEmptyValue = value.trim().length > 0; @@ -274,6 +283,16 @@ export default function SQLInlineEditor({ return true; }, }, + { + key: 'Backspace', + run: view => { + const { from, to } = view.state.selection.main; + if (from === 0 && to === 0 && onRemoveLastChipRef.current) { + return onRemoveLastChipRef.current() === true; + } + return false; + }, + }, ...(allowMultiline ? [ { @@ -324,6 +343,7 @@ export default function SQLInlineEditor({ allowMultiline && !isExpanded ? styles.collapseFade : undefined, )} ps="4px" + data-has-chips={filterChips != null ? 'true' : undefined} > {label != null && ( )} + {filterChips}
& { + min-width: min(100%, 300px); + } + + textarea { + border: none !important; + background: transparent !important; + word-break: break-all; + } + + textarea::placeholder { + transform: translateY(1px); + } +} + /* Dropdown content */ .aboveSuggestions { display: flex; diff --git a/packages/app/src/components/SearchInput/AutocompleteInput.tsx b/packages/app/src/components/SearchInput/AutocompleteInput.tsx index 0669f30a11..a224bc149b 100644 --- a/packages/app/src/components/SearchInput/AutocompleteInput.tsx +++ b/packages/app/src/components/SearchInput/AutocompleteInput.tsx @@ -28,6 +28,8 @@ export default function AutocompleteInput({ language, onSubmit, queryHistoryType, + filterChips, + onRemoveLastChip, 'data-testid': dataTestId, }: { inputRef: React.RefObject; @@ -47,6 +49,9 @@ export default function AutocompleteInput({ onLanguageChange?: (language: 'sql' | 'lucene') => void; language?: 'sql' | 'lucene'; queryHistoryType?: string; + filterChips?: React.ReactNode; + /** Returns true if a chip was actually removed so the host can consume the keystroke. */ + onRemoveLastChip?: () => boolean | void; 'data-testid'?: string; }) { const suggestionsLimit = 10; @@ -180,117 +185,143 @@ export default function AutocompleteInput({ }} > -