Skip to content

feat: render active filters as inline chips in search where input#2388

Open
MikeShi42 wants to merge 5 commits into
mainfrom
mikeshi/filter-chips-in-where-input
Open

feat: render active filters as inline chips in search where input#2388
MikeShi42 wants to merge 5 commits into
mainfrom
mikeshi/filter-chips-in-where-input

Conversation

@MikeShi42
Copy link
Copy Markdown
Contributor

Summary

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 — making filter state feel native to the input rather than parked beside it.

What changed

  • New InlineFilterChips component + filterPillUtils helpers (flattenFilters, removePill) that turn a FilterState into a list of chips and route removal back through the existing filter-state hook.
  • Chip slot wiring: SQLInlineEditor, AutocompleteInput, and SearchInputV2 all gain optional filterChips and onRemoveLastChip props.
  • SearchWhereInput accepts a new searchFilters?: FilterStateHook prop, flattens it into chips, and forwards both the chip nodes and the last-chip removal callback to whichever underlying editor is active.
  • Removed ActiveFilterPills (and its test) — its responsibility is now subsumed by SearchWhereInput.
  • DBSearchPage drops the <ActiveFilterPills .../> row and passes its searchFilters hook to SearchWhereInput instead.

Tests

  • Unit: InlineFilterChips.test.tsx (rendering, group container, no artificial limit), filterPillUtils.test.ts (flatten + remove semantics).
  • E2E: tests/e2e/features/search/filter-chips.spec.ts + a FilterChipsComponent page object.

Test plan

  • Open /search, apply a couple of sidebar filters (e.g. ServiceName, SeverityText) — verify chips appear inside the where input, not below it.
  • Click the × on a chip — filter is removed from the URL and the chip disappears.
  • Place cursor at position 0 of an empty input and press Backspace — last chip is removed.
  • Press Backspace at position 0 with text in the input — no chip removed, no text deleted.
  • Switch between SQL and Lucene modes — chips persist and behave identically in both.
  • Switch sources (logs ↔ traces) — chips that survive the source switch (per feat: preserve compatible filters when switching sources #2314) re-render correctly inside the input.
  • Long chip values wrap nicely; input still resizes on focus.

Notes

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 30, 2026

🦋 Changeset detected

Latest commit: 1dbfe55

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 3 packages
Name Type
@hyperdx/app Minor
@hyperdx/api Minor
@hyperdx/otel-collector Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel Bot commented May 30, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hyperdx-oss Ready Ready Preview, Comment Jun 1, 2026 10:29pm
hyperdx-storybook Ready Ready Preview, Comment Jun 1, 2026 10:29pm

Request Review

@github-actions github-actions Bot added the review/tier-3 Standard — full human review required label May 30, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 30, 2026

🟡 Tier 3 — Standard

Introduces new logic, modifies core functionality, or touches areas with non-trivial risk.

Why this tier:

  • Diff size: 978 production lines changed (Tier 2 max: < 250)

Review process: Full human review — logic, architecture, edge cases.
SLA: First-pass feedback within 1 business day.

Stats
  • Production files changed: 12
  • Production lines changed: 978 (+ 1282 in test files, excluded from tier calculation)
  • Branch: mikeshi/filter-chips-in-where-input
  • Author: MikeShi42

To override this classification, remove the review/tier-3 label and apply a different review/tier-* label. Manual overrides are preserved on subsequent pushes.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 30, 2026

E2E Test Results

All tests passed • 216 passed • 3 skipped • 1391s

Status Count
✅ Passed 216
❌ Failed 0
⚠️ Flaky 5
⏭️ Skipped 3

Tests ran across 4 shards in parallel.

View full report →

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 30, 2026

Deep Review

✅ No critical issues found.

🟡 P2 -- recommended

  • packages/app/src/components/filterPillUtils.ts:48 -- removing a range chip calls clearFilter(pill.field) which deletes the entire field entry, silently dropping any coexisting included/excluded values; setFilterRange deliberately allows all three to coexist on one key.
    • Fix: scope range removal to the range portion only (e.g. clear draft[key].range and remove the key only when included/excluded are also empty) so other filters on the same field are untouched.
    • adversarial
  • packages/app/src/components/SearchInput/__tests__/InlineFilterChips.test.tsx:126 -- the multi-chip test renders three chips and asserts the count, but never clicks a specific chip's X to verify its keyed onRemove(pill) callback fires for the right pill; a stale-closure regression on the per-chip handler would slip past.
    • Fix: add an assertion that clicking the second chip's remove button invokes handleRemove with the second pill's identity (field + value).
    • testing, adversarial
  • packages/app/src/components/SQLEditor/SQLInlineEditor.tsx:287 -- the new CodeMirror Backspace keybinding (guarded by view.composing and from===0 && to===0) has no unit coverage; the e2e suite only exercises the Lucene path, so a CodeMirror upgrade altering view.composing semantics would regress silently.
    • Fix: add a unit test for the SQL Backspace handler covering empty doc, non-empty doc, and composition states, or expand filter-chips.spec.ts to repeat the chip-removal assertions in SQL mode.
    • testing
  • packages/app/src/components/SearchInput/AutocompleteInput.tsx:173 -- the IME composition guard (isComposing || keyCode === 229) and its CodeMirror counterpart view.composing are added specifically for CJK correctness but no test fires a Backspace keydown during composition; removing the guard would silently break CJK input.
    • Fix: add a unit test that dispatches a keydown Backspace event with isComposing: true (and an equivalent for the SQL path) and asserts no chip is removed.
    • testing, julik-frontend-races, adversarial
🔵 P3 nitpicks (7)
  • packages/app/src/components/filterPillUtils.ts:53 -- PillItem.rawValue is optional but removePill uses pill.rawValue! for the included/excluded branches, with the non-null property guaranteed only by producer convention in flattenFilters.
    • Fix: model PillItem as a discriminated union where rawValue is required on the included/excluded variants and absent on range, then narrow on pill.type in removePill instead of asserting.
    • correctness, maintainability, kieran-typescript
  • packages/app/src/components/SearchInput/InlineFilterChips.tsx:86 -- the React key ${pill.field}-${pill.type}-${pill.value} collides when a Set contains both a boolean and its string twin (true and "true", or 404 and "404"); rehydration paths that bypass coerceBooleanValue can produce this state, causing React to render only one chip and route the X to the wrong filter.
    • Fix: include a discriminator such as typeof pill.rawValue in the key, or key by stable index within each field's set.
    • adversarial
  • packages/app/src/components/SearchInput/InlineFilterChips.tsx:90 -- onRemove={() => handleRemove(pill)} is a fresh arrow per parent render, so the outer memo plus the inner InlineFilterChip re-render every chip on every keystroke; harmless at small N but compounds with the unbounded-chip-render footprint when a saved view restores hundreds of facet values.
    • Fix: stabilize the per-chip onRemove via useCallback keyed on pill identity, and consider an overflow cap (+N more) in flattenFilters / InlineFilterChips for very large filter sets.
    • adversarial
  • packages/app/src/components/filterPillUtils.ts:50 -- removing an excluded chip calls setFilterValue(field, value, 'exclude'), whose toggle branch in searchFilters.tsx:466-473 unconditionally deletes the same value from included first; if a field somehow holds the same value in both sets, the chip X also strips the unrelated included sibling.
    • Fix: add non-toggling primitives (removeFromIncluded, removeFromExcluded) and call those from removePill so chip removal never touches the opposite set.
    • adversarial
  • packages/app/src/components/filterPillUtils.ts:1 -- the utility module and its exports use "Pill" (filterPillUtils, PillItem, removePill) while everything added in the same PR -- component, CSS, test IDs, props -- uses "Chip"; readers and grep have to translate between the two vocabularies.
    • Fix: rename to filterChipUtils.ts / FilterChip / removeChip so the feature uses one noun, or document an explicit data-vs-visual distinction.
    • maintainability
  • packages/app/src/components/SearchInput/AutocompleteInput.tsx:189 -- Backspace at position 0 removes the last chip even when the textarea has text (cursor moved to start via Home/click); the e2e test at filter-chips.spec.ts:248 asserts this is intended, but it diverges from common chip-input idioms (Mantine TagsInput, GitHub labels) which require the input to be empty.
    • Fix: treat as a UX call -- if the documented test-plan line ("no chip removed, no text deleted") is the intended contract, update the e2e assertion and add an e.target.value.length === 0 (and view.state.doc.length === 0 for SQL) guard; otherwise keep behavior and note the deliberate idiom choice somewhere durable.
    • correctness, adversarial
  • packages/app/src/components/SearchInput/SearchWhereInput.tsx:onRemoveLastChip -- setFilterValue without action: 'only' is a toggle (searchFilters.tsx:478-483); if two Backspace keydowns fire faster than React commits the post-removal render, the second call sees the just-removed state and toggles the chip back on; same applies to a fast double-click on a chip X.
    • Fix: add an explicit non-toggling remove action to setFilterValue (or a removeOnly flag) and route removePill through it; alternatively guard with a ref that records the most-recent removal target within the same microtask.
    • julik-frontend-races

Reviewers (9): correctness, testing, maintainability, project-standards, kieran-typescript, julik-frontend-races, adversarial, agent-native, learnings-researcher.

Testing gaps:

  • No coverage for fields that hold range + include/exclude simultaneously -- the range-removal data-loss path (P2) is exercised by neither unit nor e2e tests.
  • No unit test asserts Backspace is a no-op when selectionStart === 0 but selectionEnd > 0 (zero-anchored selection).
  • No test covers chip rendering with embedded newlines, tabs, zero-width characters, or RTL text in filter values.
  • No test covers source switch (logs ↔ traces) with an in-flight chip removal targeting a pill that disappears during retainFiltersByColumns.

@elizabetdev
Copy link
Copy Markdown
Contributor

@MikeShi42 can you take a look at this PR? #2360 or HDX-4381

Previously, when a user switched the active source on the search page (e.g. Logs → Traces), any filters with fields not present in the new source were silently dropped. A transient yellow toast notified the user, but their selection and the context for switching back was lost.

The idea of the PR ##2360 was to keep those filters visible in the ActiveFilterPills bar using a muted, strikethrough, dashed-border style, along with a tooltip explaining why they aren’t applied. The filters are excluded from the generated query to keep it valid, and they automatically reapply if the user switches back to a compatible source.

Would be possible to achieve something similar in this PR? Do you think that proposal makes sense?

@MikeShi42
Copy link
Copy Markdown
Contributor Author

@elizabetdev I wonder if zooming out - if our source switching mechanic needs a bit of a rethink to solve these issues. It seems like sometimes a user may expect the source to not reset the search page, and sometimes it makes sense to reset the search page.

I can't think of any good ideas right now (maybe some sort of "source groups" concept, but that seems overly complex, or "switch without clearing search" option in the dropdown). I'm curious if you have any ideas that might address the issue at a broader level.

I think the strike out filters can probably be done here if we'd like, though personally I'm worried it makes the experience more complex/confusing rather than simpler, so I'm hesitant to lean into it unless we think it's really critical to patch right now.

MikeShi42 added 3 commits June 1, 2026 11:24
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)
- SCSS: collapse `:not(.expanded):not([data-has-chips])` into a single
  complex `:not(.expanded, [data-has-chips])` to satisfy stylelint's
  `selector-not-notation` rule.
- Unit: remove the `@/components/ActiveFilterPills` jest.mock from
  DBSearchPage.directTrace.test.tsx since the component was deleted.
- E2E: the sidebar writes filters in Lucene form (`field:"value"`), not
  SQL `'value'`, so the URL-sync wait check in `applySidebarFilter`
  never matched. Switch to a value-substring check and bump the timeout
  to 10s for slower CI environments.
@MikeShi42 MikeShi42 force-pushed the mikeshi/filter-chips-in-where-input branch from 95fa195 to f9d6e54 Compare June 1, 2026 18:25
setSelectedQueryHistoryIndex(-1);
setIsSearchInputFocused(false);
}}
onKeyDown={e => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we pull this out instead of inlining?

Comment on lines +285 to +288
if (
e.key === 'ArrowDown' &&
e.target instanceof HTMLTextAreaElement
) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm arrow up and down on autocomplete is not working for me like it did previously. But it also isn't working on play-clickstack.clickhouse.com.

}

.chipExcluded {
border-color: var(--mantine-color-red-light);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image

This doesn't look very red in light mode

dateRange={searchedTimeRange}
sourceId={inputSource}
size="xs"
searchFilters={searchFilters}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suuuuper cool that this is the only diff required on this component

// Inline filter chips
const filters = searchFilters?.filters;
const pills = useMemo(
() => (filters ? flattenFilters(filters) : []),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

style: flattenFilters could accept FilterState | undefined and just have a guard returning [] if undefined. Then this memo is simplified

- SearchWhereInput: gate `filterChips` slot and `data-has-chips` on
  `pills.length > 0`, not on `searchFilters` being wired. Previously
  DBSearchPage (which always passes the hook) flipped the attribute on
  unconditionally, breaking the SQL Paper's `:not(.expanded,
  [data-has-chips]) { max-height: 36px }` cap and the Lucene
  collapse-fade gradient for the empty-filter state.
- AutocompleteInput: when Backspace removes a chip, reset
  `selectedAutocompleteIndex` and close the suggestion dropdown,
  mirroring the Escape branch. Without this, an arrow-key-highlighted
  suggestion lingers and the next Enter inserts an unintended token.
- AutocompleteInput + SQLInlineEditor: skip the Backspace-removes-chip
  handler during IME composition (CJK input). The textarea value is
  empty mid-composition, which previously caused Backspace to eat a
  chip instead of editing the composition.
- Tighten `onRemoveLastChip` to `() => boolean` across SearchInputV2,
  AutocompleteInput, and SQLInlineEditor so the "consume the keystroke"
  contract is enforced by the type system end to end.
- InlineFilterChips: drop the array index from the chip key. Field +
  type + value is already unique (the underlying sets dedupe), and
  removing a middle chip no longer remounts every subsequent chip's
  Tooltip / focus state.
- Re-comment the `.mantine-InputWrapper-root` SCSS rule — Mantine still
  emits that wrapper, so the rule is live and load-bearing for Lucene
  sizing, not legacy.
- AutocompleteInput: pull the inline onKeyDown handler out into a
  named `handleKeyDown` useCallback so the JSX body stays readable.
  Also wrap `onAcceptSuggestion` in useCallback now that it's a
  dependency of `handleKeyDown`.
- InlineFilterChips.module.scss: swap the excluded chip border from
  `--mantine-color-red-light` (a pink background tint that barely
  reads as red in light mode) to `--color-text-danger`, which already
  swings red-3 ↔ red-8 by theme.
- filterPillUtils: `flattenFilters` now accepts `FilterState |
  undefined` and returns `[]` for the undefined case, so callers
  don't need to guard externally. SearchWhereInput's memo collapses
  to a single call.
@elizabetdev
Copy link
Copy Markdown
Contributor

@MikeShi42 agreed, let's not overcomplicate this.

I'd keep the reset for now. Not resetting causes more headaches than it's worth.

Middle ground: people can already pin go-to filters per source. More repeat-use than in-the-moment continuity, but that's probably the common case.

Agree the broader mechanic deserves a proper rethink though. Let's revisit it down the line once we've got more signal on what people actually expect.

Also what do you twink of removing the WHERE inside the input? We already have it in the placeholder.
Screenshot 2026-06-02 at 11 30 29

@karl-power
Copy link
Copy Markdown
Contributor

@elizabetdev @MikeShi42 Does that mean we can close/cancel Wire up inactive filter preservation on source switch?

@elizabetdev
Copy link
Copy Markdown
Contributor

@karl-power, we can close it or we can rename the issue and continue the discussion there.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/tier-3 Standard — full human review required

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants