diff --git a/.changeset/filter-chips-in-where-input.md b/.changeset/filter-chips-in-where-input.md new file mode 100644 index 0000000000..f49bf2f2ad --- /dev/null +++ b/.changeset/filter-chips-in-where-input.md @@ -0,0 +1,11 @@ +--- +'@hyperdx/app': minor +--- + +feat: render active filters as inline chips in search where input + +Active sidebar filters now appear as chips rendered inline inside the +search WHERE input itself, replacing the separate row of pills that +previously sat below the input. Chips render in both SQL and Lucene +modes, can be removed with the chip's × button, and Backspace at the +start of an empty input removes the last chip. 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 && ( ({ DBTimeChart: () =>
, })); -jest.mock('@/components/ActiveFilterPills', () => ({ - ActiveFilterPills: () =>
, -})); jest.mock('@/components/ContactSupportText', () => ({ ContactSupportText: () =>
, })); diff --git a/packages/app/src/components/ActiveFilterPills.tsx b/packages/app/src/components/ActiveFilterPills.tsx deleted file mode 100644 index 763cafbb19..0000000000 --- a/packages/app/src/components/ActiveFilterPills.tsx +++ /dev/null @@ -1,270 +0,0 @@ -import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { ActionIcon, Flex, FlexProps, Text, Tooltip } from '@mantine/core'; -import { IconX } from '@tabler/icons-react'; - -import type { FilterStateHook } from '@/searchFilters'; - -const MAX_VISIBLE_PILLS = 8; - -type PillItem = { - field: string; - value: string; - type: 'included' | 'excluded' | 'range'; - rawValue?: string | boolean; -}; - -function flattenFilters(filters: FilterStateHook['filters']): PillItem[] { - const pills: PillItem[] = []; - for (const [field, state] of Object.entries(filters)) { - for (const val of state.included) { - pills.push({ - field, - value: String(val), - type: 'included', - rawValue: val, - }); - } - for (const val of state.excluded) { - pills.push({ - field, - value: String(val), - type: 'excluded', - rawValue: val, - }); - } - if (state.range != null) { - pills.push({ - field, - value: `${state.range.min} – ${state.range.max}`, - type: 'range', - }); - } - } - return pills; -} - -const pillStyle = { - display: 'inline-flex', - alignItems: 'center', - gap: 4, - padding: '1px 6px', - borderRadius: 3, - fontSize: 11, - lineHeight: '18px', - cursor: 'default', - whiteSpace: 'nowrap' as const, - maxWidth: 260, - overflow: 'hidden', -}; - -function FilterPill({ - pill, - isInvalid, - invalidReason, - onRemove, -}: { - pill: PillItem; - isInvalid?: boolean; - invalidReason?: string; - onRemove: () => 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..3e6eedd34a 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, [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..597d830018 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; }; 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,20 @@ export default function SQLInlineEditor({ return true; }, }, + { + key: 'Backspace', + run: view => { + // Skip during IME composition so the chip-removal shortcut + // doesn't fire while uncommitted CJK glyphs are still in the + // composition layer. + if (view.composing) return false; + const { from, to } = view.state.selection.main; + if (from === 0 && to === 0 && onRemoveLastChipRef.current) { + return onRemoveLastChipRef.current(); + } + return false; + }, + }, ...(allowMultiline ? [ { @@ -324,6 +347,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..7a8de086be 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; 'data-testid'?: string; }) { const suggestionsLimit = 10; @@ -115,34 +120,37 @@ export default function AutocompleteInput({ onSubmit?.(); // search }; - const onAcceptSuggestion = (suggestion: string) => { - setSelectedAutocompleteIndex(-1); + const onAcceptSuggestion = useCallback( + (suggestion: string) => { + setSelectedAutocompleteIndex(-1); - if (value == null || !tokenInfo) { - onChange(suggestion); - inputRef.current?.focus(); - return; - } + if (value == null || !tokenInfo) { + onChange(suggestion); + inputRef.current?.focus(); + return; + } - // Replace the token at cursor with the suggestion - const tokens = [...tokenInfo.tokens]; - tokens[tokenInfo.index] = suggestion; - const newValue = tokens.join(' '); + // Replace the token at cursor with the suggestion + const tokens = [...tokenInfo.tokens]; + tokens[tokenInfo.index] = suggestion; + const newValue = tokens.join(' '); - // Place cursor right after the inserted suggestion - let newCursorPos = 0; - for (let i = 0; i <= tokenInfo.index; i++) { - newCursorPos += tokens[i].length; - if (i < tokenInfo.index) newCursorPos++; // space - } + // Place cursor right after the inserted suggestion + let newCursorPos = 0; + for (let i = 0; i <= tokenInfo.index; i++) { + newCursorPos += tokens[i].length; + if (i < tokenInfo.index) newCursorPos++; // space + } - onChange(newValue); + onChange(newValue); - requestAnimationFrame(() => { - inputRef.current?.setSelectionRange(newCursorPos, newCursorPos); - inputRef.current?.focus(); - }); - }; + requestAnimationFrame(() => { + inputRef.current?.setSelectionRange(newCursorPos, newCursorPos); + inputRef.current?.focus(); + }); + }, + [value, tokenInfo, onChange, inputRef], + ); const ref = useRef(null); useLayoutEffect(() => { if (ref.current) { @@ -156,6 +164,108 @@ export default function AutocompleteInput({ // Height including the 2px border from .textarea (1px top + 1px bottom) const baseHeight = size === 'xs' ? 30 : size === 'lg' ? 44 : 38; + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + // Ignore keystrokes that belong to an in-progress IME composition + // (CJK input). At this point the textarea value is still empty while + // uncommitted glyphs live in the composition layer, so Backspace + // would otherwise eat a chip instead of editing the composition. + if (e.nativeEvent.isComposing || e.keyCode === 229) { + return; + } + + if (e.key === 'Escape' && e.target instanceof HTMLTextAreaElement) { + e.preventDefault(); + setIsInputDropdownOpen(false); + e.target.blur(); + return; + } + + // Backspace at cursor position 0 removes last chip (only if a chip + // was actually present — otherwise let backspace behave normally). + if ( + e.key === 'Backspace' && + e.target instanceof HTMLTextAreaElement && + e.target.selectionStart === 0 && + e.target.selectionEnd === 0 && + onRemoveLastChip + ) { + const removed = onRemoveLastChip(); + if (removed) { + e.preventDefault(); + // Removing a chip changes what the user is targeting, so a + // stale arrow-key suggestion selection would cause the next + // Enter to insert an unintended token. Reset and close the + // dropdown to mirror the Escape branch. + setSelectedAutocompleteIndex(-1); + setIsInputDropdownOpen(false); + } + return; + } + + if (!(e.target instanceof HTMLTextAreaElement)) return; + + const hasSelectableSuggestion = + suggestedProperties.length > 0 && + selectedAutocompleteIndex >= 0 && + selectedAutocompleteIndex < suggestedProperties.length; + + if (e.key === 'Tab' && hasSelectableSuggestion) { + e.preventDefault(); + onAcceptSuggestion( + suggestedProperties[selectedAutocompleteIndex].value, + ); + return; + } + + if (e.key === 'Enter') { + if (hasSelectableSuggestion) { + e.preventDefault(); + onAcceptSuggestion( + suggestedProperties[selectedAutocompleteIndex].value, + ); + } else if (!e.shiftKey) { + // Allow shift+enter to still create new lines + e.preventDefault(); + if (queryHistoryType && value) { + setQueryHistory(value); + } + onSubmit?.(); + } + return; + } + + if (e.key === 'ArrowDown' && suggestedProperties.length > 0) { + e.preventDefault(); + setSelectedAutocompleteIndex( + Math.min( + selectedAutocompleteIndex + 1, + suggestedProperties.length - 1, + suggestionsLimit - 1, + ), + ); + return; + } + + if (e.key === 'ArrowUp' && suggestedProperties.length > 0) { + e.preventDefault(); + setSelectedAutocompleteIndex( + Math.max(selectedAutocompleteIndex - 1, 0), + ); + } + }, + [ + onRemoveLastChip, + suggestedProperties, + selectedAutocompleteIndex, + onAcceptSuggestion, + queryHistoryType, + value, + setQueryHistory, + onSubmit, + ], + ); + return (
-