Skip to content

Commit a31ab36

Browse files
authored
feat(ui): click-to-filter chips on test names (#231)
## Summary Each chip on a test name (gas, opcode, fork, params, labels) is now a button. Click toggles the matching term in the page search; the chip lights up blue while that term is pinned. Lets you drill into "all 90M tests" or "all ORIGIN tests" without typing. To make chip filters precise without losing typed-search convenience, the filter syntax now supports two separators: - **`key:value`** — substring (existing behavior). `opcode:ORIG` matches `ORIGIN`. - **`key=value`** — exact match. `calldata_size=0` matches only `calldata_size_0`, not `calldata_size_1024`. Chip clicks emit this form. A shared `TEST_FILTER_HINT` constant feeds every search-box tooltip so they stay in sync. ## Wired in - `run-detail/TestsTable` name cell - `run-detail/TestHeatmap` modal - `suite-detail/TestHeatmap` row labels - `compare/TestComparisonTable` (plumbed from ComparePage) - `compare/TestDetailModal` (plumbed from CompareGroupsPage) Heatmap tooltips stay non-interactive on purpose (`pointer-events-none`). ## Test plan - [ ] Click an `ORIGIN` opcode chip in the tests table — `opcode=ORIGIN` is added to the search, chip turns blue, the table filters. Click again — term removed, chip un-styled. - [ ] Type `opcode:ORIG` manually — table filters by substring; chips don't light up since the typed term is the substring form. - [ ] Click `0` on a `calldata_size:0` chip — only `calldata_size_0` rows remain (not `calldata_size_1024`). - [ ] Click a chip in the run-detail heatmap modal and the compare detail modal — both pin the term to the page filter. - [ ] Active chips stay highlighted across name-mode and theme toggles.
1 parent 4077f4c commit a31ab36

12 files changed

Lines changed: 251 additions & 65 deletions

File tree

ui/src/components/compare/StickyRunBar.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
22
import clsx from 'clsx'
33
import { type CompareRun, type LabelMode, buildLabelModeOptions, RUN_SLOTS, formatRunLabel } from './constants'
44
import { FilterInput } from '@/components/shared/FilterInput'
5+
import { TEST_FILTER_HINT } from '@/utils/eestNameFilter'
56

67
interface StickyRunBarProps {
78
runs: CompareRun[]
@@ -77,9 +78,7 @@ export function StickyRunBar({ runs, sentinelRef, labelMode, onLabelModeChange,
7778
<span>Filter:</span>
7879
<FilterInput
7980
placeholder={testFilterRegex ? 'Regex...' : 'Filter or e.g. opcode:ORIGIN'}
80-
title={testFilterRegex
81-
? 'Regex against the raw test name.'
82-
: 'Free text matches the raw name. Or filter by extracted fields:\nopcode:ORIGIN gas:90M fork:Amsterdam file:tx_context fn:codecopy path:compute label:LOG1\nUnrecognized keys hit params: mem_size:1024 code_size:0\nMultiple terms are AND.'}
81+
title={testFilterRegex ? 'Regex against the raw test name.' : TEST_FILTER_HINT}
8382
value={testFilter}
8483
onValueChange={onTestFilterChange}
8584
className={clsx(

ui/src/components/compare/TestComparisonTable.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ interface TestComparisonTableProps {
2020
sortDir: SortDirection
2121
onSortChange: (column: SortColumn, direction: SortDirection) => void
2222
testNameFilter?: (name: string) => boolean
23+
/** Current search query string used to highlight matching chips. */
24+
searchQuery?: string
25+
/** Toggle a `key:value` term in the page-level search. */
26+
onChipFilterToggle?: (term: string) => void
2327
/** When provided, clicking a row calls this with the test name (for opening a detail modal). */
2428
onTestClick?: (testName: string) => void
2529
}
@@ -134,7 +138,7 @@ function SortableHeader({
134138

135139
const PAGE_SIZE = 50
136140

137-
export function TestComparisonTable({ runs, suiteTests, stepFilter, blockLogsPerRun, labelMode, tableBaseline, onTableBaselineChange, sortBy, sortDir, onSortChange, testNameFilter, onTestClick }: TestComparisonTableProps) {
141+
export function TestComparisonTable({ runs, suiteTests, stepFilter, blockLogsPerRun, labelMode, tableBaseline, onTableBaselineChange, sortBy, sortDir, onSortChange, testNameFilter, searchQuery, onChipFilterToggle, onTestClick }: TestComparisonTableProps) {
138142
const [activeTab, setActiveTab] = useState('mgas')
139143
const [currentPage, setCurrentPage] = useState(1)
140144

@@ -403,7 +407,7 @@ export function TestComparisonTable({ runs, suiteTests, stepFilter, blockLogsPer
403407
{test.order || '-'}
404408
</td>
405409
<td className="max-w-sm truncate px-4 py-2 text-sm/6 text-gray-900 dark:text-gray-100">
406-
<TestName name={test.name} />
410+
<TestName name={test.name} onChipClick={onChipFilterToggle} activeQuery={searchQuery} />
407411
</td>
408412
<td className="whitespace-nowrap px-4 py-2 text-right text-xs/5 text-gray-500 dark:text-gray-400">
409413
{test.gasUsed !== undefined ? `${Math.round(test.gasUsed / 1_000_000)}M` : '-'}

ui/src/components/compare/TestDetailModal.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ interface TestDetailModalProps {
2121
groupRunIds: string[][]
2222
stepFilter: StepTypeOption[]
2323
sampleSize: number
24+
/** Current page-level search query (used to highlight active chips). */
25+
searchQuery?: string
26+
/** Toggle a `key:value` term in the page-level search. */
27+
onChipFilterToggle?: (term: string) => void
2428
onClose: () => void
2529
}
2630

@@ -38,6 +42,8 @@ export function TestDetailModal({
3842
groupRunIds,
3943
stepFilter,
4044
sampleSize,
45+
searchQuery,
46+
onChipFilterToggle,
4147
onClose,
4248
}: TestDetailModalProps) {
4349
const SLOT_COLORS = ['text-blue-700 dark:text-blue-300', 'text-orange-700 dark:text-orange-300', 'text-purple-700 dark:text-purple-300', 'text-green-700 dark:text-green-300', 'text-red-700 dark:text-red-300']
@@ -148,7 +154,7 @@ export function TestDetailModal({
148154
{testOrder !== undefined ? `Test #${testOrder}` : 'Test Detail'}
149155
</h3>
150156
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
151-
<TestName name={testName} showRawBelow showCopy />
157+
<TestName name={testName} showRawBelow showCopy onChipClick={onChipFilterToggle} activeQuery={searchQuery} />
152158
</div>
153159
</div>
154160
<button onClick={onClose} className="shrink-0 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300">

ui/src/components/run-detail/TestHeatmap.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type { TestEntry, SuiteTest, AggregatedStats, MethodsAggregated, StepResu
66
import { fetchHead } from '@/api/client'
77
import { Modal } from '@/components/shared/Modal'
88
import { TestName } from '@/components/shared/TestName'
9-
import { testNameMatches } from '@/utils/eestNameFilter'
9+
import { testNameMatches, toggleSearchTerm } from '@/utils/eestNameFilter'
1010
import { TimeBreakdown } from './TimeBreakdown'
1111
import { MGasBreakdown } from './MGasBreakdown'
1212
import { ExecutionsList } from './ExecutionsList'
@@ -377,6 +377,7 @@ export function TestHeatmap({
377377
postTestRPCCalls,
378378
inProgressTestKey,
379379
onSelectedTestChange,
380+
onSearchChange,
380381
onSortModeChange,
381382
onGroupModeChange,
382383
onThresholdChange,
@@ -945,7 +946,14 @@ export function TestHeatmap({
945946
<div className="flex flex-col gap-2">
946947
<div>
947948
<div className="text-sm/6 text-gray-900 dark:text-gray-100">
948-
<TestName name={selectedTest} showRawBelow showCopy className="min-w-0" />
949+
<TestName
950+
name={selectedTest}
951+
showRawBelow
952+
showCopy
953+
onChipClick={onSearchChange ? (term) => onSearchChange(toggleSearchTerm(searchQuery, term)) : undefined}
954+
activeQuery={searchQuery}
955+
className="min-w-0"
956+
/>
949957
</div>
950958
</div>
951959
{entry.dir && (

ui/src/components/run-detail/TestsTable.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { Badge } from '@/components/shared/Badge'
66
import { Duration } from '@/components/shared/Duration'
77
import { Pagination } from '@/components/shared/Pagination'
88
import { TestName } from '@/components/shared/TestName'
9-
import { testNameMatches } from '@/utils/eestNameFilter'
9+
import { testNameMatches, toggleSearchTerm, TEST_FILTER_HINT } from '@/utils/eestNameFilter'
1010
import { type StepTypeOption, ALL_STEP_TYPES } from '@/pages/RunDetailPage'
1111

1212
export type TestSortColumn = 'order' | 'name' | 'genesis' | 'time' | 'mgas' | 'passed' | 'failed'
@@ -310,7 +310,7 @@ export function TestsTable({
310310
<input
311311
type="text"
312312
placeholder="Search… or e.g. opcode:ORIGIN gas:90M"
313-
title={`Free text matches anywhere in the raw name. Or filter by extracted fields:\nopcode:ORIGIN gas:90M fork:Amsterdam file:tx_context fn:codecopy path:compute label:LOG1\nUnrecognized keys hit params: mem_size:1024 code_size:0\nMultiple terms are AND.`}
313+
title={TEST_FILTER_HINT}
314314
value={searchQuery}
315315
onChange={(e) => handleSearchInput(e.target.value)}
316316
className="w-72 rounded-sm border border-gray-300 bg-white px-3 py-2 text-sm/6 placeholder:text-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-500"
@@ -359,7 +359,12 @@ export function TestsTable({
359359
{executionOrder.get(testName) ?? '-'}
360360
</td>
361361
<td className="max-w-md px-4 py-3">
362-
<TestName name={testName} className="text-sm/6 font-medium text-gray-900 dark:text-gray-100" />
362+
<TestName
363+
name={testName}
364+
onChipClick={onSearchChange ? (term) => onSearchChange(toggleSearchTerm(searchQuery, term)) : undefined}
365+
activeQuery={searchQuery}
366+
className="text-sm/6 font-medium text-gray-900 dark:text-gray-100"
367+
/>
363368
{entry.dir && (
364369
<div className="truncate text-xs/5 text-gray-500 dark:text-gray-400" title={entry.dir}>
365370
{entry.dir}

ui/src/components/shared/TestName.tsx

Lines changed: 104 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState } from 'react'
22
import clsx from 'clsx'
33
import { Check, Copy } from 'lucide-react'
44
import { parseEESTName } from '@/utils/eestName'
5+
import { searchQueryContains } from '@/utils/eestNameFilter'
56
import { useNameDisplayMode } from '@/hooks/useNameDisplayMode'
67

78
interface TestNameProps {
@@ -19,6 +20,16 @@ interface TestNameProps {
1920
* mode (in raw mode the primary line already shows the raw name).
2021
*/
2122
showRawBelow?: boolean
23+
/**
24+
* If provided, chips become buttons. Called with the search-query term
25+
* to toggle (e.g. `opcode:ORIGIN`, `gas:90M`, `mem_size:1024`).
26+
*/
27+
onChipClick?: (term: string) => void
28+
/**
29+
* Current search query. Chips whose term is present here render with
30+
* an "active" style so users can see what's pinned.
31+
*/
32+
activeQuery?: string
2233
className?: string
2334
}
2435

@@ -59,8 +70,53 @@ const chipAccent =
5970
'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-950/50 dark:text-indigo-300 dark:ring-indigo-800'
6071
const chipGas =
6172
'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-950/50 dark:text-emerald-300 dark:ring-emerald-800'
73+
const chipActive =
74+
'bg-blue-500 text-white ring-blue-500 dark:bg-blue-500 dark:text-white dark:ring-blue-500'
75+
76+
function Chip({
77+
variant,
78+
label,
79+
term,
80+
onClick,
81+
active,
82+
}: {
83+
variant: 'gas' | 'accent' | 'neutral'
84+
label: string
85+
term: string
86+
onClick?: (term: string) => void
87+
active?: boolean
88+
}) {
89+
const variantClasses =
90+
active ? chipActive
91+
: variant === 'gas' ? chipGas
92+
: variant === 'accent' ? chipAccent
93+
: chipNeutral
94+
95+
if (!onClick) {
96+
return <span className={clsx(chipBase, variantClasses)}>{label}</span>
97+
}
98+
return (
99+
<button
100+
type="button"
101+
onClick={(e) => {
102+
e.stopPropagation()
103+
e.preventDefault()
104+
onClick(term)
105+
}}
106+
className={clsx(
107+
chipBase,
108+
variantClasses,
109+
'cursor-pointer transition-colors',
110+
!active && 'hover:bg-blue-100 hover:text-blue-700 hover:ring-blue-300 dark:hover:bg-blue-950/60 dark:hover:text-blue-300 dark:hover:ring-blue-700',
111+
)}
112+
title={active ? `Click to remove ${term} from filter` : `Click to filter by ${term}`}
113+
>
114+
{label}
115+
</button>
116+
)
117+
}
62118

63-
export function TestName({ name, variant = 'full', showCopy = false, showRawBelow = false, className }: TestNameProps) {
119+
export function TestName({ name, variant = 'full', showCopy = false, showRawBelow = false, onChipClick, activeQuery, className }: TestNameProps) {
64120
const { mode } = useNameDisplayMode()
65121
const parsed = mode === 'decomposed' ? parseEESTName(name) : null
66122

@@ -112,24 +168,58 @@ export function TestName({ name, variant = 'full', showCopy = false, showRawBelo
112168
{hasChips && (
113169
<span className="inline-flex flex-wrap items-baseline gap-1">
114170
{parsed.benchmark && (
115-
<span className={clsx(chipBase, chipGas)}>{parsed.benchmark}</span>
171+
<Chip
172+
variant="gas"
173+
label={parsed.benchmark}
174+
term={`gas=${parsed.benchmark}`}
175+
onClick={onChipClick}
176+
active={!!activeQuery && searchQueryContains(activeQuery, `gas=${parsed.benchmark}`)}
177+
/>
116178
)}
117179
{parsed.opcode && (
118-
<span className={clsx(chipBase, chipAccent)}>{parsed.opcode}</span>
180+
<Chip
181+
variant="accent"
182+
label={parsed.opcode}
183+
term={`opcode=${parsed.opcode}`}
184+
onClick={onChipClick}
185+
active={!!activeQuery && searchQueryContains(activeQuery, `opcode=${parsed.opcode}`)}
186+
/>
119187
)}
120188
{parsed.fork && (
121-
<span className={clsx(chipBase, chipNeutral)}>fork:{parsed.fork}</span>
189+
<Chip
190+
variant="neutral"
191+
label={`fork:${parsed.fork}`}
192+
term={`fork=${parsed.fork}`}
193+
onClick={onChipClick}
194+
active={!!activeQuery && searchQueryContains(activeQuery, `fork=${parsed.fork}`)}
195+
/>
122196
)}
123-
{parsed.params.map(({ key, value }) => (
124-
<span key={`${key}=${value}`} className={clsx(chipBase, chipNeutral)}>
125-
{key}:{value}
126-
</span>
127-
))}
128-
{parsed.labels.map((label) => (
129-
<span key={label} className={clsx(chipBase, chipNeutral)}>
130-
{label}
131-
</span>
132-
))}
197+
{parsed.params.map(({ key, value }) => {
198+
const term = `${key}=${value}`
199+
return (
200+
<Chip
201+
key={term}
202+
variant="neutral"
203+
label={`${key}:${value}`}
204+
term={term}
205+
onClick={onChipClick}
206+
active={!!activeQuery && searchQueryContains(activeQuery, term)}
207+
/>
208+
)
209+
})}
210+
{parsed.labels.map((label) => {
211+
const term = `label=${label}`
212+
return (
213+
<Chip
214+
key={label}
215+
variant="neutral"
216+
label={label}
217+
term={term}
218+
onClick={onChipClick}
219+
active={!!activeQuery && searchQueryContains(activeQuery, term)}
220+
/>
221+
)
222+
})}
133223
</span>
134224
)}
135225
{showRawBelow && (

ui/src/components/suite-detail/OpcodeHeatmap.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { SuiteTest } from '@/api/types'
55
import { getGroupedOpcodes, getCategoryColor } from '@/utils/opcodeCategories'
66
import type { CategorySpan, GroupedResult } from '@/utils/opcodeCategories'
77
import { TestName } from '@/components/shared/TestName'
8-
import { testNameMatches } from '@/utils/eestNameFilter'
8+
import { testNameMatches, TEST_FILTER_HINT } from '@/utils/eestNameFilter'
99

1010
/** Returns the opcode count map from the top-level field or EEST info fallback. */
1111
function getOpcodeCount(test: SuiteTest): Record<string, number> | undefined {
@@ -945,7 +945,7 @@ export function OpcodeHeatmap({ tests, onTestClick, extraColumns = [], searchQue
945945
<input
946946
type="text"
947947
placeholder="Filter… or e.g. opcode:ORIGIN"
948-
title={'Free text matches the raw name. Or filter by extracted fields:\nopcode:ORIGIN gas:90M fork:Amsterdam file:tx_context fn:codecopy path:compute label:LOG1\nUnrecognized keys hit params: mem_size:1024 code_size:0\nMultiple terms are AND.'}
948+
title={TEST_FILTER_HINT}
949949
value={search}
950950
onChange={(e) => setSearch(e.target.value)}
951951
className="rounded-xs border border-gray-300 bg-white px-3 py-1 text-sm/6 placeholder-gray-400 focus:border-blue-500 focus:outline-hidden focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"

ui/src/components/suite-detail/TestHeatmap.tsx

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { JDenticon } from '@/components/shared/JDenticon'
88
import { Pagination } from '@/components/shared/Pagination'
99
import { Spinner } from '@/components/shared/Spinner'
1010
import { TestName } from '@/components/shared/TestName'
11-
import { testNameMatches } from '@/utils/eestNameFilter'
11+
import { testNameMatches, toggleSearchTerm, TEST_FILTER_HINT } from '@/utils/eestNameFilter'
1212
import { formatTimestamp } from '@/utils/date'
1313

1414
const DEFAULT_PAGE_SIZE = 20
@@ -528,9 +528,7 @@ export function TestHeatmap({ stats, testFiles, isDark, isLoading, suiteHash, su
528528
value={search}
529529
onChange={(e) => handleSearchChange(e.target.value)}
530530
placeholder={useRegex ? 'Regex...' : 'Filter or e.g. opcode:ORIGIN'}
531-
title={useRegex
532-
? 'Regex against the raw test name.'
533-
: 'Free text matches the raw name. Or filter by extracted fields:\nopcode:ORIGIN gas:90M fork:Amsterdam file:tx_context fn:codecopy path:compute label:LOG1\nUnrecognized keys hit params: mem_size:1024 code_size:0\nMultiple terms are AND.'}
531+
title={useRegex ? 'Regex against the raw test name.' : TEST_FILTER_HINT}
534532
className={clsx(
535533
'w-28 rounded-xs border bg-white px-2 py-1 text-sm placeholder-gray-400 focus:outline-hidden focus:ring-1 sm:w-auto sm:px-3 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500',
536534
useRegex && search && (() => { try { new RegExp(search); return false } catch { return true } })()
@@ -734,7 +732,11 @@ export function TestHeatmap({ stats, testFiles, isDark, isLoading, suiteHash, su
734732
<HighlightedName name={test.name} search={search} useRegex={useRegex} />
735733
</span>
736734
) : (
737-
<TestName name={test.name} />
735+
<TestName
736+
name={test.name}
737+
onChipClick={(term) => onSearchChange?.(toggleSearchTerm(search, term) || undefined)}
738+
activeQuery={search}
739+
/>
738740
)}
739741
</td>
740742
</tr>

ui/src/pages/CompareGroupsPage.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useQueries } from '@tanstack/react-query'
44
import clsx from 'clsx'
55
import type { RunConfig, RunResult } from '@/api/types'
66
import { fetchData } from '@/api/client'
7-
import { testNameMatches } from '@/utils/eestNameFilter'
7+
import { testNameMatches, toggleSearchTerm, TEST_FILTER_HINT } from '@/utils/eestNameFilter'
88
import { useIndex } from '@/api/hooks/useIndex'
99
import { useSuite } from '@/api/hooks/useSuite'
1010
import { LoadingState } from '@/components/shared/Spinner'
@@ -450,7 +450,7 @@ export function CompareGroupsPage() {
450450
placeholder={testFilterRegex ? 'Regex...' : 'Filter or e.g. opcode:ORIGIN'}
451451
title={testFilterRegex
452452
? 'Regex against the raw test name.'
453-
: 'Free text matches the raw name. Or filter by extracted fields:\nopcode:ORIGIN gas:90M fork:Amsterdam file:tx_context fn:codecopy path:compute label:LOG1\nUnrecognized keys hit params: mem_size:1024 code_size:0\nMultiple terms are AND.'}
453+
: TEST_FILTER_HINT}
454454
value={testFilter}
455455
onChange={(e) => updateSearch({ filter: e.target.value || undefined })}
456456
className={clsx(
@@ -579,9 +579,7 @@ export function CompareGroupsPage() {
579579
<input
580580
type="text"
581581
placeholder={testFilterRegex ? 'Regex pattern...' : 'Filter… or e.g. opcode:ORIGIN'}
582-
title={testFilterRegex
583-
? 'Regex against the raw test name.'
584-
: 'Free text matches the raw name. Or filter by extracted fields:\nopcode:ORIGIN gas:90M fork:Amsterdam file:tx_context fn:codecopy path:compute label:LOG1\nUnrecognized keys hit params: mem_size:1024 code_size:0\nMultiple terms are AND.'}
582+
title={testFilterRegex ? 'Regex against the raw test name.' : TEST_FILTER_HINT}
585583
value={testFilter}
586584
onChange={(e) => updateSearch({ filter: e.target.value || undefined })}
587585
className={clsx(
@@ -741,6 +739,8 @@ export function CompareGroupsPage() {
741739
groupRunIds={groupRuns}
742740
stepFilter={stepFilter}
743741
sampleSize={sampleSize}
742+
searchQuery={testFilter}
743+
onChipFilterToggle={(term) => updateSearch({ filter: toggleSearchTerm(testFilter, term) || undefined })}
744744
onClose={() => setSelectedTest(null)}
745745
/>
746746
)}

0 commit comments

Comments
 (0)