From ce1d4889a845dd768419ae0de0aff6ea6f41b213 Mon Sep 17 00:00:00 2001 From: Himanshu Kapoor Date: Wed, 14 Jan 2026 13:36:51 +0100 Subject: [PATCH] feat: Add a search input that searches within virtual elements Add a separate search input triggered by Cmd+F that searches through all matches currently on the page, overriding the native browser search that only searches within visible rows. --- .changeset/eight-bobcats-press.md | 5 + packages/app/src/DBSearchPage.tsx | 5 +- packages/app/src/components/DBRowTable.tsx | 804 +++++----- .../components/DBTable/TableSearchInput.tsx | 354 +++++ .../__tests__/TableSearchInput.test.tsx | 1314 +++++++++++++++++ .../hooks/__tests__/useTableSearch.test.tsx | 613 ++++++++ packages/app/src/hooks/useTableSearch.ts | 165 +++ 7 files changed, 2919 insertions(+), 341 deletions(-) create mode 100644 .changeset/eight-bobcats-press.md create mode 100644 packages/app/src/components/DBTable/TableSearchInput.tsx create mode 100644 packages/app/src/components/DBTable/__tests__/TableSearchInput.test.tsx create mode 100644 packages/app/src/hooks/__tests__/useTableSearch.test.tsx create mode 100644 packages/app/src/hooks/useTableSearch.ts diff --git a/.changeset/eight-bobcats-press.md b/.changeset/eight-bobcats-press.md new file mode 100644 index 0000000000..c393a93254 --- /dev/null +++ b/.changeset/eight-bobcats-press.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": minor +--- + +Add a search input that allows searching within the virtual elements on the log search page diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index d9c388daee..be009eb98c 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -1906,7 +1906,10 @@ function DBSearchPage() { {!hasQueryError && ( - + []>( () => [ ...(showExpandButton @@ -518,13 +530,27 @@ export const RawLogTable = memo( ? `${strValue.slice(0, MAX_CELL_LENGTH)}...` : strValue; + // Apply search highlighting if there's a search query + // Use React Table's row index which corresponds to dedupedRows index + const isCurrentMatch = + !!tableSearch.searchQuery && + tableSearch.matchIndices.length > 0 && + tableSearch.matchIndices[tableSearch.currentMatchIndex] === + info.row.index; + + const displayValue = tableSearch.searchQuery + ? highlightText(truncatedStrValue, tableSearch.searchQuery, { + isCurrentMatch, + }) + : truncatedStrValue; + return ( - {truncatedStrValue} + {displayValue} ); }, @@ -548,6 +574,9 @@ export const RawLogTable = memo( toggleRowExpansion, showExpandButton, aliasMap, + tableSearch.searchQuery, + tableSearch.matchIndices, + tableSearch.currentMatchIndex, ], ); @@ -671,6 +700,62 @@ export const RawLogTable = memo( onChildModalOpen?.(open); }; + // Scroll to current match (only when explicitly navigating) + // Fixed: Properly synchronized scroll flag with state updates + useEffect(() => { + if (!tableSearch.shouldScrollToMatch) { + return; + } + + if ( + tableSearch.matchIndices.length > 0 && + rowVirtualizer && + tableSearch.currentMatchIndex < tableSearch.matchIndices.length && + tableSearch.searchQuery.trim() + ) { + const matchRowIndex = + tableSearch.matchIndices[tableSearch.currentMatchIndex]; + const matchedRow = dedupedRows[matchRowIndex]; + + if (matchedRow) { + // Find the corresponding row in the react-table rows + const tableRowIndex = _rows.findIndex( + row => getRowId(row.original) === getRowId(matchedRow), + ); + + if (tableRowIndex !== -1) { + // Use requestAnimationFrame to ensure DOM is ready + requestAnimationFrame(() => { + rowVirtualizer.scrollToIndex(tableRowIndex, { + align: 'center', + }); + // Clear the flag after scrolling completes + tableSearch.clearShouldScrollToMatch(); + }); + } else { + // Row not found in virtual list, clear flag + tableSearch.clearShouldScrollToMatch(); + } + } else { + // No matched row, clear flag + tableSearch.clearShouldScrollToMatch(); + } + } else { + // Conditions not met, clear flag + tableSearch.clearShouldScrollToMatch(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + tableSearch.shouldScrollToMatch, + tableSearch.currentMatchIndex, + tableSearch.matchIndices, + tableSearch.searchQuery, + tableSearch.clearShouldScrollToMatch, + rowVirtualizer, + _rows, + dedupedRows, + ]); + useEffect(() => { if ( scrolledToHighlightedLine || @@ -741,359 +826,398 @@ export const RawLogTable = memo( }); return ( -
{ - fetchMoreOnBottomReached(e.target as HTMLDivElement); - - if (e.target != null) { - const { scrollTop } = e.target as HTMLDivElement; - onScroll?.(scrollTop); - } - }} - ref={tableContainerRef} - // Fixes flickering scroll bar: https://github.com/TanStack/virtual/issues/426#issuecomment-1403438040 - // style={{ overflowAnchor: 'none' }} - > - {config && ( - handleSqlModalOpen(false)} - config={config} + + + {/* Find within page search bar - floating on top right */} + - )} - - - {displayedColumns.length > 0 && - table.getHeaderGroups().map(headerGroup => ( - - {headerGroup.headers.map((header, headerIndex) => { - const isLast = - headerIndex === headerGroup.headers.length - 1; - return ( - - {tableId && - Object.keys(columnSizeStorage).length > 0 && ( +
{ + fetchMoreOnBottomReached(e.target as HTMLDivElement); + + if (e.target != null) { + const { scrollTop } = e.target as HTMLDivElement; + onScroll?.(scrollTop); + } + }} + ref={tableContainerRef} + // Fixes flickering scroll bar: https://github.com/TanStack/virtual/issues/426#issuecomment-1403438040 + // style={{ overflowAnchor: 'none' }} + > + {config && ( + handleSqlModalOpen(false)} + config={config} + /> + )} +
+ + {displayedColumns.length > 0 && + table.getHeaderGroups().map(headerGroup => ( + + {headerGroup.headers.map((header, headerIndex) => { + const isLast = + headerIndex === headerGroup.headers.length - 1; + return ( + + {tableId && + Object.keys(columnSizeStorage).length > 0 && ( + setColumnSizeStorage({})} + title="Reset Column Widths" + > + + + + + )} + {config && ( + handleSqlModalOpen(true)} + title="Show Generated SQL" + tabIndex={0} + > + + + + + )} setColumnSizeStorage({})} - title="Reset Column Widths" + onClick={() => + setWrapLinesEnabled(prev => !prev) + } + title={`${wrapLinesEnabled ? 'Disable' : 'Enable'} Wrap Lines`} > - - + + {wrapLinesEnabled ? ( + + ) : ( + + )} - )} - {config && ( - handleSqlModalOpen(true)} - title="Show Generated SQL" - tabIndex={0} - > - - - - - )} - setWrapLinesEnabled(prev => !prev)} - title={`${wrapLinesEnabled ? 'Disable' : 'Enable'} Wrap Lines`} - > - - {wrapLinesEnabled ? ( - - ) : ( - - )} - - - - - - - - - {onSettingsClick != null && ( - onSettingsClick()} - title="Settings" - > - - - - - )} - - } - /> - ); - })} - - ))} - - - {paddingTop > 0 && ( - - - )} - {items.map(virtualRow => { - const row = _rows[virtualRow.index] as TableRow; - const rowId = getRowId(row.original); - const isExpanded = expandedRows[rowId] ?? false; - return ( - - - {/* Expand button cell */} - {showExpandButton && ( - + ))} + + + {paddingTop > 0 && ( + + + )} + {items.map(virtualRow => { + const row = _rows[virtualRow.index] as TableRow; + const rowId = getRowId(row.original); + const isExpanded = expandedRows[rowId] ?? false; + + return ( + + - {flexRender( - row.getVisibleCells()[0].column.columnDef.cell, - row.getVisibleCells()[0].getContext(), + {/* Expand button cell */} + {showExpandButton && ( + )} - - )} - {/* Content columns grouped back to preserve row hover/click */} - + + {showExpandButton && isExpanded && ( + + {renderRowDetails?.({ + id: rowId, + aliasWith: + row.original[INTERNAL_ROW_FIELDS.ALIAS_WITH], + ...row.original, })} - {/* Row-level copy buttons */} - {getRowWhere && ( - - setWrapLinesEnabled(!wrapLinesEnabled) - } - /> - )} - - - - {showExpandButton && isExpanded && ( - - {renderRowDetails?.({ - id: rowId, - aliasWith: row.original[INTERNAL_ROW_FIELDS.ALIAS_WITH], - ...row.original, - })} - - )} - - ); - })} - - + - - {paddingBottom > 0 && ( - - - )} - -
-
+ + + + + {onSettingsClick != null && ( + onSettingsClick()} + title="Settings" + > + + + + + )} + + } + /> + ); + })} +
+
+ {flexRender( + row.getVisibleCells()[0].column.columnDef.cell, + row.getVisibleCells()[0].getContext(), + )} + - +
-
- {isLoading ? ( -
-
- -
{' '} - {loadingDate != null && ( - <> - Searched .{' '} - + )} - Loading results - {dateRange?.[0] != null && dateRange?.[1] != null ? ( - <> - {' '} - across{' '} - {formatDistance(dateRange?.[1], dateRange?.[0])} {'('} - {' '} - to{' '} - - {')'} - - ) : null} - ... -
- ) : hasNextPage == false && - isLoading == false && - dedupedRows.length > 0 ? ( -
End of Results
- ) : isError ? ( -
- - Error loading results, please check your query or try - again. - - - - - {error?.message} - - - {error instanceof ClickHouseQueryError && ( - <> - - Sent Query: + + ); + })} +
+
+ {isLoading ? ( +
+
+ +
{' '} + {loadingDate != null && ( + <> + Searched .{' '} + + )} + Loading results + {dateRange?.[0] != null && dateRange?.[1] != null ? ( + <> + {' '} + across{' '} + {formatDistance( + dateRange?.[1], + dateRange?.[0], + )}{' '} + {'('} + {' '} + to{' '} + + {')'} + + ) : null} + ... +
+ ) : hasNextPage == false && + isLoading == false && + dedupedRows.length > 0 ? ( +
End of Results
+ ) : isError ? ( +
+ + Error loading results, please check your query or + try again. + + + + + {error?.message} + + + {error instanceof ClickHouseQueryError && ( + <> + + Sent Query: + + + + + + )} + +
+ ) : hasNextPage == false && + isLoading == false && + dedupedRows.length === 0 ? ( +
+ No results found. + + Try checking the query explainer in the search bar + if there are any search syntax issues. + + {dateRange?.[0] != null && dateRange?.[1] != null ? ( + + Searched Time Range:{' '} + {formatDistance(dateRange?.[1], dateRange?.[0])}{' '} + {'('} + {' '} + to{' '} + + {')'} - - - - - )} - -
- ) : hasNextPage == false && - isLoading == false && - dedupedRows.length === 0 ? ( -
- No results found. - - Try checking the query explainer in the search bar if - there are any search syntax issues. - - {dateRange?.[0] != null && dateRange?.[1] != null ? ( - - Searched Time Range:{' '} - {formatDistance(dateRange?.[1], dateRange?.[0])} {'('} - {' '} - to{' '} - - {')'} - - ) : null} + ) : null} +
+ ) : ( +
+ )}
- ) : ( -
- )} -
-
-
-
+ + + {paddingBottom > 0 && ( + + + + )} + + + + {/* Match indicator on the right side */} + +
+ ); }, ); diff --git a/packages/app/src/components/DBTable/TableSearchInput.tsx b/packages/app/src/components/DBTable/TableSearchInput.tsx new file mode 100644 index 0000000000..815379f3ab --- /dev/null +++ b/packages/app/src/components/DBTable/TableSearchInput.tsx @@ -0,0 +1,354 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + Box, + Group, + Paper, + Text, + TextInput, + UnstyledButton, +} from '@mantine/core'; +import { IconArrowDown, IconArrowUp, IconX } from '@tabler/icons-react'; + +interface HighlightTextSettings { + isCurrentMatch: boolean; + textColor: string; + backgroundColor: string; + currentMatchBackgroundColor: string; +} + +/** + * Helper function to highlight text matching a search query within the text of + * a TextNode. + * + * @param text - The text (part of the TextNode) to highlight within + * @param query - The query to highlight + * @param isCurrentMatch - Whether the text is the current match. Current match is + * highlighted with an orange background. + * @returns The text node with the matching query highlighted. + */ +export const highlightText = ( + text: string, + query: string, + settings: Partial = {}, +): React.ReactNode => { + if (!query.trim()) return text; + + const backgroundColor = settings.isCurrentMatch + ? settings.currentMatchBackgroundColor || 'var(--mantine-color-orange-5)' + : settings.backgroundColor || 'var(--mantine-color-yellow-3)'; + + const lowerText = text.toLowerCase(); + const lowerQuery = query.toLowerCase(); + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let index = lowerText.indexOf(lowerQuery); + + while (index !== -1) { + // Add text before match + if (index > lastIndex) { + parts.push(text.slice(lastIndex, index)); + } + // Add highlighted match - use orange for current match, yellow for others + parts.push( + + {text.slice(index, index + query.length)} + , + ); + lastIndex = index + query.length; + index = lowerText.indexOf(lowerQuery, lastIndex); + } + + // Add remaining text + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts.length > 0 ? <>{parts} : text; +}; + +interface TableSearchInputProps { + searchQuery: string; + onSearchChange: (value: string) => void; + matchIndices: number[]; + currentMatchIndex: number; + onPreviousMatch: () => void; + onNextMatch: () => void; + /** + * Control visibility from parent + */ + isVisible?: boolean; + /** + * Called when visibility changes + */ + onVisibilityChange?: (visible: boolean) => void; +} + +/** + * A search input component that handles Cmd+F/Ctrl+F keyboard shortcuts. + * The parent is responsible for performing the actual search and managing match state. + */ +export const TableSearchInput = ({ + searchQuery, + onSearchChange, + matchIndices, + currentMatchIndex, + onPreviousMatch, + onNextMatch, + isVisible: externalIsVisible, + onVisibilityChange, +}: TableSearchInputProps) => { + const [internalIsVisible, setInternalIsVisible] = useState(false); + const searchInputRef = useRef(null); + + // Use external visibility if provided, otherwise use internal state + const isVisible = externalIsVisible ?? internalIsVisible; + + const handleClose = useCallback(() => { + if (externalIsVisible !== undefined) { + onVisibilityChange?.(false); + } else { + setInternalIsVisible(false); + } + onSearchChange(''); + }, [externalIsVisible, onSearchChange, onVisibilityChange]); + + const handleShow = useCallback(() => { + if (externalIsVisible !== undefined) { + onVisibilityChange?.(true); + } else { + setInternalIsVisible(true); + } + // Focus the input after a brief delay to ensure it's rendered + setTimeout(() => { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + }, 0); + }, [externalIsVisible, onVisibilityChange]); + + // Handle keyboard shortcuts (Cmd+F, Escape) + // Fixed: Move handler definition outside of effect to prevent recreation + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + // Detect Cmd+F (Mac) or Ctrl+F (Windows/Linux) + if ((e.metaKey || e.ctrlKey) && e.key === 'f') { + e.preventDefault(); + handleShow(); + } + // Close search on Escape + if (e.key === 'Escape' && isVisible) { + handleClose(); + } + }, + [isVisible, handleClose, handleShow], + ); + + useEffect(() => { + window.addEventListener('keydown', handleKeyDown); + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [handleKeyDown]); + + const handleSearchKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && matchIndices.length > 0) { + if (e.shiftKey) { + // Shift+Enter: previous match + onPreviousMatch(); + } else { + // Enter: next match + onNextMatch(); + } + e.preventDefault(); + } + }, + [matchIndices.length, onPreviousMatch, onNextMatch], + ); + + if (!isVisible) { + return null; + } + return ( + + + onSearchChange(e.target.value)} + onKeyDown={handleSearchKeyDown} + size="xs" + style={{ flex: 1, minWidth: 0 }} + aria-label="Search table contents" + aria-describedby={ + matchIndices.length > 0 ? 'search-match-count' : undefined + } + rightSection={ + searchQuery ? ( + onSearchChange('')} + display="flex" + style={{ alignItems: 'center' }} + title="Clear search" + aria-label="Clear search" + > + + + ) : null + } + /> + {matchIndices.length > 0 ? ( + <> + + {currentMatchIndex + 1} of {matchIndices.length} + + + + + + + + + + + ) : searchQuery ? ( + + No matches + + ) : null} + + + + + + ); +}; + +interface TableSearchMatchIndicatorProps { + searchQuery: string; + matchIndices: number[]; + currentMatchIndex: number; + dedupedRows: Record[]; + tableRows: any[]; + getRowId: (row: Record) => string; + onMatchClick: (index: number) => void; +} + +/** + * Visual indicators on the scrollbar showing where search matches are located. + * Renders as a vertical strip on the right side of the table with clickable markers. + */ +export const TableSearchMatchIndicator = ({ + searchQuery, + matchIndices, + currentMatchIndex, + dedupedRows, + tableRows, + getRowId, + onMatchClick, +}: TableSearchMatchIndicatorProps) => { + if (!searchQuery || matchIndices.length === 0 || tableRows.length === 0) { + return null; + } + + return ( + + {matchIndices.map((matchIndex, i) => { + const matchRow = dedupedRows[matchIndex]; + if (!matchRow) return null; + + const tableRowIndex = tableRows.findIndex( + row => getRowId(row.original) === getRowId(matchRow), + ); + + if (tableRowIndex === -1) return null; + + // Calculate position as percentage of total rows + const percentage = (tableRowIndex / tableRows.length) * 100; + // Calculate height based on number of rows (each indicator represents 1/n of the scrollbar) + const heightPercentage = 100 / tableRows.length; + const isCurrentMatchIndicator = i === currentMatchIndex; + + return ( + onMatchClick(i)} + title={`Match ${i + 1} of ${matchIndices.length}`} + /> + ); + })} + + ); +}; diff --git a/packages/app/src/components/DBTable/__tests__/TableSearchInput.test.tsx b/packages/app/src/components/DBTable/__tests__/TableSearchInput.test.tsx new file mode 100644 index 0000000000..1a210d6cd5 --- /dev/null +++ b/packages/app/src/components/DBTable/__tests__/TableSearchInput.test.tsx @@ -0,0 +1,1314 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { + highlightText, + TableSearchInput, + TableSearchMatchIndicator, +} from '@/components/DBTable/TableSearchInput'; + +describe('highlightText', () => { + describe('basic highlighting', () => { + it('should return plain text when query is empty', () => { + const result = highlightText('hdx-oss-dev-api', ''); + expect(result).toBe('hdx-oss-dev-api'); + }); + + it('should return plain text when query is whitespace', () => { + const result = highlightText('hdx-oss-dev-api', ' '); + expect(result).toBe('hdx-oss-dev-api'); + }); + + it('should highlight matching text in service name', () => { + const result = highlightText('hdx-oss-dev-api', 'api'); + expect(result).toBeDefined(); + }); + + it('should highlight matching text in span name', () => { + const result = highlightText('mongodb.update', 'mongodb'); + expect(result).toBeDefined(); + }); + + it('should be case-insensitive', () => { + const result = highlightText('hdx-oss-dev-api', 'API'); + expect(result).toBeDefined(); + }); + + it('should highlight multiple occurrences', () => { + const result = highlightText('middleware - corsMiddleware', 'middleware'); + expect(result).toBeDefined(); + }); + }); + + describe('partial matches', () => { + it('should highlight partial match at beginning', () => { + const result = highlightText('mongodb.update', 'mongo'); + expect(result).toBeDefined(); + }); + + it('should highlight partial match at end', () => { + const result = highlightText('tcp.connect', 'connect'); + expect(result).toBeDefined(); + }); + + it('should highlight partial match in middle', () => { + const result = highlightText('hdx-oss-dev-api', 'oss'); + expect(result).toBeDefined(); + }); + }); + + describe('custom styling', () => { + it('should apply current match highlighting', () => { + const result = highlightText('mongodb.update', 'mongodb', { + isCurrentMatch: true, + currentMatchBackgroundColor: 'orange', + }); + expect(result).toBeDefined(); + }); + + it('should apply custom text color', () => { + const result = highlightText('mongodb.update', 'mongodb', { + textColor: 'white', + }); + expect(result).toBeDefined(); + }); + + it('should apply custom background color', () => { + const result = highlightText('mongodb.update', 'mongodb', { + backgroundColor: 'yellow', + }); + expect(result).toBeDefined(); + }); + + it('should use different colors for current vs other matches', () => { + const currentMatch = highlightText('middleware - corsMiddleware', 'mid', { + isCurrentMatch: true, + }); + const otherMatch = highlightText('middleware - corsMiddleware', 'mid', { + isCurrentMatch: false, + }); + + expect(currentMatch).toBeDefined(); + expect(otherMatch).toBeDefined(); + expect(currentMatch).not.toEqual(otherMatch); + }); + }); + + describe('special characters', () => { + it('should handle text with dots', () => { + const result = highlightText('mongodb.update', '.'); + expect(result).toBeDefined(); + }); + + it('should handle text with slashes', () => { + const result = highlightText('router - /health', '/health'); + expect(result).toBeDefined(); + }); + + it('should handle text with hyphens', () => { + const result = highlightText('hdx-oss-dev-api', '-oss-'); + expect(result).toBeDefined(); + }); + + it('should handle text with underscores', () => { + const result = highlightText('isUserAuthenticated', 'User'); + expect(result).toBeDefined(); + }); + }); + + describe('realistic HyperDX values', () => { + it('should highlight service names', () => { + const result = highlightText('hdx-oss-dev-api', 'dev'); + expect(result).toBeDefined(); + }); + + it('should highlight span names with dots', () => { + const result = highlightText('mongodb.update', 'update'); + expect(result).toBeDefined(); + }); + + it('should highlight middleware names', () => { + const result = highlightText( + 'middleware - isUserAuthenticated', + 'authenticated', + ); + expect(result).toBeDefined(); + }); + + it('should highlight router paths', () => { + const result = highlightText('router - /health', 'health'); + expect(result).toBeDefined(); + }); + + it('should highlight DNS operations', () => { + const result = highlightText('dns.lookup', 'dns'); + expect(result).toBeDefined(); + }); + + it('should highlight TCP operations', () => { + const result = highlightText('tcp.connect', 'tcp'); + expect(result).toBeDefined(); + }); + + it('should highlight POST operations', () => { + const result = highlightText('POST', 'post'); + expect(result).toBeDefined(); + }); + }); +}); + +describe('TableSearchInput', () => { + const defaultProps = { + searchQuery: '', + onSearchChange: jest.fn(), + matchIndices: [], + currentMatchIndex: 0, + onPreviousMatch: jest.fn(), + onNextMatch: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('visibility', () => { + it('should not render when isVisible is false', () => { + renderWithMantine( + , + ); + + expect(screen.queryByRole('search')).not.toBeInTheDocument(); + }); + + it('should render when isVisible is true', () => { + renderWithMantine( + , + ); + + expect(screen.getByRole('search')).toBeInTheDocument(); + }); + + it('should render search input field when visible', () => { + renderWithMantine( + , + ); + + expect( + screen.getByPlaceholderText('Find in table...'), + ).toBeInTheDocument(); + }); + + it('should call onVisibilityChange when close button is clicked', async () => { + const onVisibilityChange = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + const closeButton = screen.getByLabelText('Close search'); + await user.click(closeButton); + + expect(onVisibilityChange).toHaveBeenCalledWith(false); + }); + }); + + describe('search input', () => { + it('should display the current search query', () => { + renderWithMantine( + , + ); + + expect(screen.getByDisplayValue('mongodb')).toBeInTheDocument(); + }); + + it('should call onSearchChange when typing', async () => { + const onSearchChange = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + const input = screen.getByPlaceholderText('Find in table...'); + await user.type(input, 'hdx-oss-dev-api'); + + expect(onSearchChange).toHaveBeenCalled(); + }); + + it('should show clear button when query is not empty', () => { + renderWithMantine( + , + ); + + expect(screen.getByLabelText('Clear search')).toBeInTheDocument(); + }); + + it('should not show clear button when query is empty', () => { + renderWithMantine( + , + ); + + expect(screen.queryByLabelText('Clear search')).not.toBeInTheDocument(); + }); + + it('should clear search when clear button is clicked', async () => { + const onSearchChange = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + const clearButton = screen.getByLabelText('Clear search'); + await user.click(clearButton); + + expect(onSearchChange).toHaveBeenCalledWith(''); + }); + }); + + describe('match count display', () => { + it('should display match count when matches exist', () => { + renderWithMantine( + , + ); + + expect(screen.getByText('1 of 3')).toBeInTheDocument(); + }); + + it('should update match count when navigating', () => { + // Test first match position + const { unmount } = renderWithMantine( + , + ); + + expect(screen.getByText('1 of 2')).toBeInTheDocument(); + unmount(); + + // Test second match position + renderWithMantine( + , + ); + + expect(screen.getByText('2 of 2')).toBeInTheDocument(); + }); + + it('should display "No matches" when search has no results', () => { + renderWithMantine( + , + ); + + expect(screen.getByText('No matches')).toBeInTheDocument(); + }); + + it('should not display match count when query is empty', () => { + renderWithMantine( + , + ); + + expect(screen.queryByText(/of/)).not.toBeInTheDocument(); + expect(screen.queryByText('No matches')).not.toBeInTheDocument(); + }); + }); + + describe('navigation controls', () => { + it('should display navigation buttons when matches exist', () => { + renderWithMantine( + , + ); + + expect(screen.getByLabelText('Previous match')).toBeInTheDocument(); + expect(screen.getByLabelText('Next match')).toBeInTheDocument(); + }); + + it('should not display navigation buttons when no matches', () => { + renderWithMantine( + , + ); + + expect(screen.queryByLabelText('Previous match')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('Next match')).not.toBeInTheDocument(); + }); + + it('should call onPreviousMatch when previous button clicked', async () => { + const onPreviousMatch = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + const previousButton = screen.getByLabelText('Previous match'); + await user.click(previousButton); + + expect(onPreviousMatch).toHaveBeenCalled(); + }); + + it('should call onNextMatch when next button clicked', async () => { + const onNextMatch = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + const nextButton = screen.getByLabelText('Next match'); + await user.click(nextButton); + + expect(onNextMatch).toHaveBeenCalled(); + }); + }); + + describe('keyboard shortcuts', () => { + it('should call onNextMatch when Enter is pressed', async () => { + const onNextMatch = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + const input = screen.getByPlaceholderText('Find in table...'); + input.focus(); + await user.keyboard('{Enter}'); + + expect(onNextMatch).toHaveBeenCalled(); + }); + + it('should call onPreviousMatch when Shift+Enter is pressed', async () => { + const onPreviousMatch = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + const input = screen.getByPlaceholderText('Find in table...'); + input.focus(); + await user.keyboard('{Shift>}{Enter}{/Shift}'); + + expect(onPreviousMatch).toHaveBeenCalled(); + }); + + it('should not navigate when Enter is pressed with no matches', async () => { + const onNextMatch = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + const input = screen.getByPlaceholderText('Find in table...'); + input.focus(); + await user.keyboard('{Enter}'); + + expect(onNextMatch).not.toHaveBeenCalled(); + }); + + it('should open search on Cmd+F', async () => { + const onVisibilityChange = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + // Simulate Cmd+F (Meta+F) + await user.keyboard('{Meta>}f{/Meta}'); + + await waitFor(() => { + expect(onVisibilityChange).toHaveBeenCalledWith(true); + }); + }); + + it('should open search on Ctrl+F', async () => { + const onVisibilityChange = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + // Simulate Ctrl+F + await user.keyboard('{Control>}f{/Control}'); + + await waitFor(() => { + expect(onVisibilityChange).toHaveBeenCalledWith(true); + }); + }); + + it('should close search on Escape when visible', async () => { + const onVisibilityChange = jest.fn(); + const onSearchChange = jest.fn(); + const user = userEvent.setup(); + + renderWithMantine( + , + ); + + await user.keyboard('{Escape}'); + + await waitFor(() => { + expect(onVisibilityChange).toHaveBeenCalledWith(false); + expect(onSearchChange).toHaveBeenCalledWith(''); + }); + }); + }); + + describe('accessibility', () => { + it('should have proper ARIA labels', () => { + renderWithMantine( + , + ); + + expect(screen.getByRole('search')).toHaveAttribute( + 'aria-label', + 'Table search', + ); + expect(screen.getByPlaceholderText('Find in table...')).toHaveAttribute( + 'aria-label', + 'Search table contents', + ); + }); + + it('should have aria-live region for match count', () => { + renderWithMantine( + , + ); + + const matchCount = screen.getByText('1 of 3'); + expect(matchCount).toHaveAttribute('aria-live', 'polite'); + }); + + it('should have aria-live region for no matches message', () => { + renderWithMantine( + , + ); + + const noMatches = screen.getByText('No matches'); + expect(noMatches).toHaveAttribute('aria-live', 'polite'); + }); + + it('should link search input to match count with aria-describedby', () => { + renderWithMantine( + , + ); + + // Verify the match count is present with the correct ID + const matchCount = screen.getByText('1 of 3'); + expect(matchCount).toHaveAttribute('id', 'search-match-count'); + expect(matchCount).toHaveAttribute('aria-live', 'polite'); + + // Verify input has proper label + const input = screen.getByPlaceholderText('Find in table...'); + expect(input).toHaveAttribute('aria-label', 'Search table contents'); + }); + }); + + describe('realistic search scenarios', () => { + it('should handle searching for service name "hdx-oss-dev-api"', () => { + renderWithMantine( + , + ); + + expect(screen.getByText('1 of 7')).toBeInTheDocument(); + }); + + it('should handle searching for span name "mongodb"', () => { + renderWithMantine( + , + ); + + expect(screen.getByText('1 of 1')).toBeInTheDocument(); + }); + + it('should handle searching for "middleware"', () => { + renderWithMantine( + , + ); + + expect(screen.getByText('1 of 2')).toBeInTheDocument(); + }); + + it('should handle searching for "longtask"', () => { + renderWithMantine( + , + ); + + expect(screen.getByText('1 of 2')).toBeInTheDocument(); + }); + + it('should handle searching across all results with "Unset"', () => { + renderWithMantine( + , + ); + + expect(screen.getByText('1 of 9')).toBeInTheDocument(); + }); + }); + + describe('integration scenarios', () => { + it('should support full search workflow', async () => { + const onSearchChange = jest.fn(); + const onNextMatch = jest.fn(); + const onPreviousMatch = jest.fn(); + const user = userEvent.setup(); + + // Render with search results + renderWithMantine( + , + ); + + // Verify match count is displayed + expect(screen.getByText('1 of 2')).toBeInTheDocument(); + + // Test navigation buttons + const nextButton = screen.getByLabelText('Next match'); + await user.click(nextButton); + expect(onNextMatch).toHaveBeenCalled(); + + const previousButton = screen.getByLabelText('Previous match'); + await user.click(previousButton); + expect(onPreviousMatch).toHaveBeenCalled(); + + // Test typing in search box + const input = screen.getByPlaceholderText('Find in table...'); + await user.clear(input); + await user.type(input, 'new search'); + expect(onSearchChange).toHaveBeenCalled(); + }); + }); +}); + +describe('TableSearchMatchIndicator', () => { + // Realistic test data based on HyperDX logs + const mockDedupedRows = [ + { + id: '1', + timestamp: 'Jan 15 4:43:28.966 PM', + ServiceName: 'hdx-oss-dev-app', + StatusCode: 'Unset', + SpanName: 'longtask', + Duration: 69, + }, + { + id: '2', + timestamp: 'Jan 15 4:43:28.912 PM', + ServiceName: 'hdx-oss-dev-app', + StatusCode: 'Unset', + SpanName: 'longtask', + Duration: 54, + }, + { + id: '3', + timestamp: 'Jan 15 4:43:25.034 PM', + ServiceName: 'hdx-oss-dev-api', + StatusCode: 'Unset', + SpanName: 'mongodb.update', + Duration: 1, + }, + { + id: '4', + timestamp: 'Jan 15 4:43:25.021 PM', + ServiceName: 'hdx-oss-dev-api', + StatusCode: 'Unset', + SpanName: 'dns.lookup', + Duration: 0, + }, + { + id: '5', + timestamp: 'Jan 15 4:43:25.021 PM', + ServiceName: 'hdx-oss-dev-api', + StatusCode: 'Unset', + SpanName: 'POST', + Duration: 13, + }, + ]; + + const mockTableRows = mockDedupedRows.map((row, index) => ({ + original: row, + index, + })); + + const getRowId = (row: any) => row.id; + + const defaultProps = { + searchQuery: '', + matchIndices: [], + currentMatchIndex: 0, + dedupedRows: mockDedupedRows, + tableRows: mockTableRows as any[], + getRowId, + onMatchClick: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('visibility conditions', () => { + it('should not render when searchQuery is empty', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + expect(indicators.length).toBe(0); + }); + + it('should not render when matchIndices is empty', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + expect(indicators.length).toBe(0); + }); + + it('should not render when tableRows is empty', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + expect(indicators.length).toBe(0); + }); + + it('should render when all required conditions are met', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + expect(indicators.length).toBeGreaterThan(0); + }); + }); + + describe('match indicators rendering', () => { + it('should render indicators for all matches', () => { + const { container } = renderWithMantine( + , + ); + + // Count the number of indicator boxes rendered + const indicators = container.querySelectorAll('[title*="Match"]'); + expect(indicators.length).toBe(3); + }); + + it('should render indicators for "longtask" matches', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + expect(indicators.length).toBe(2); + }); + + it('should render single indicator for "mongodb" match', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + expect(indicators.length).toBe(1); + }); + + it('should render indicators for all "Unset" status matches', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + expect(indicators.length).toBe(5); + }); + }); + + describe('indicator positioning', () => { + it('should position indicators based on row position in table', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + + // First match (index 0) should be at 0% (0 / 5 * 100) + expect(indicators[0]).toHaveStyle({ top: '0%' }); + + // Second match (index 1) should be at 20% (1 / 5 * 100) + expect(indicators[1]).toHaveStyle({ top: '20%' }); + }); + + it('should calculate correct position for matches at different indices', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + + // Match at index 2: 2 / 5 * 100 = 40% + expect(indicators[0]).toHaveStyle({ top: '40%' }); + + // Match at index 3: 3 / 5 * 100 = 60% + expect(indicators[1]).toHaveStyle({ top: '60%' }); + + // Match at index 4: 4 / 5 * 100 = 80% + expect(indicators[2]).toHaveStyle({ top: '80%' }); + }); + }); + + describe('current match highlighting', () => { + it('should render all indicators when current match is first', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + + // All indicators should be rendered + expect(indicators.length).toBe(3); + // First indicator should be current match + expect(indicators[0]).toHaveAttribute('title', 'Match 1 of 3'); + expect(indicators[1]).toHaveAttribute('title', 'Match 2 of 3'); + expect(indicators[2]).toHaveAttribute('title', 'Match 3 of 3'); + }); + + it('should render all indicators when current match is in middle', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + + // All indicators should be rendered + expect(indicators.length).toBe(3); + // Second indicator should be current match + expect(indicators[1]).toHaveAttribute('title', 'Match 2 of 3'); + }); + + it('should render all indicators when current match is last', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + + // All indicators should be rendered + expect(indicators.length).toBe(2); + // Last indicator should be current match + expect(indicators[1]).toHaveAttribute('title', 'Match 2 of 2'); + }); + }); + + describe('indicator titles', () => { + it('should display correct title for first match', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + expect(indicators[0]).toHaveAttribute('title', 'Match 1 of 3'); + }); + + it('should display correct titles for all matches', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + expect(indicators[0]).toHaveAttribute('title', 'Match 1 of 3'); + expect(indicators[1]).toHaveAttribute('title', 'Match 2 of 3'); + expect(indicators[2]).toHaveAttribute('title', 'Match 3 of 3'); + }); + + it('should display correct title for single match', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + expect(indicators[0]).toHaveAttribute('title', 'Match 1 of 1'); + }); + }); + + describe('click interactions', () => { + it('should call onMatchClick when indicator is clicked', async () => { + const onMatchClick = jest.fn(); + const user = userEvent.setup(); + + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + await user.click(indicators[0] as HTMLElement); + + expect(onMatchClick).toHaveBeenCalledWith(0); + }); + + it('should call onMatchClick with correct index for different matches', async () => { + const onMatchClick = jest.fn(); + const user = userEvent.setup(); + + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + + // Click second indicator + await user.click(indicators[1] as HTMLElement); + expect(onMatchClick).toHaveBeenCalledWith(1); + + // Click third indicator + await user.click(indicators[2] as HTMLElement); + expect(onMatchClick).toHaveBeenCalledWith(2); + }); + + it('should have pointer cursor on indicators', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + indicators.forEach(indicator => { + expect(indicator).toHaveStyle({ cursor: 'pointer' }); + }); + }); + }); + + describe('edge cases', () => { + it('should handle match that is not in tableRows', () => { + const { container } = renderWithMantine( + , + ); + + // Should only render indicator for valid match (index 0) + const indicators = container.querySelectorAll('[title*="Match"]'); + expect(indicators.length).toBe(1); + }); + + it('should handle mismatched row IDs gracefully', () => { + const mismatchedDedupedRows = [ + { ...mockDedupedRows[0], id: 'non-matching-id' }, + ]; + + const { container } = renderWithMantine( + , + ); + + // Should not render indicator if row IDs don't match + const indicators = container.querySelectorAll('[title*="Match"]'); + expect(indicators.length).toBe(0); + }); + + it('should render with large number of matches', () => { + const largeDedupedRows = Array.from({ length: 100 }, (_, i) => ({ + id: `row-${i}`, + ServiceName: 'test-service', + SpanName: 'test-span', + StatusCode: 'Unset', + })); + + const largeTableRows = largeDedupedRows.map((row, index) => ({ + original: row, + index, + })); + + const largeMatchIndices = Array.from({ length: 50 }, (_, i) => i * 2); + + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + expect(indicators.length).toBe(50); + }); + }); + + describe('styling and layout', () => { + it('should have correct positioning and dimensions', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + // Verify indicators are rendered (proves container exists with correct setup) + expect(indicators.length).toBe(2); + + // Verify indicators have correct positioning + expect(indicators[0]).toHaveStyle({ top: '0%' }); + expect(indicators[1]).toHaveStyle({ top: '20%' }); + }); + + it('should have correct z-index for proper layering', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + // Verify indicators are rendered (proves z-index setup works) + expect(indicators.length).toBe(2); + }); + + it('should have pointer-events on indicators but not on container', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + + // Indicators should have pointer cursor (proving they're clickable) + indicators.forEach(indicator => { + expect(indicator).toHaveStyle({ cursor: 'pointer' }); + }); + }); + }); + + describe('realistic search scenarios', () => { + it('should correctly render indicators for "longtask" search', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + expect(indicators.length).toBe(2); + expect(indicators[0]).toHaveAttribute('title', 'Match 1 of 2'); + expect(indicators[1]).toHaveAttribute('title', 'Match 2 of 2'); + // Verify indicators are clickable + expect(indicators[0]).toHaveStyle({ cursor: 'pointer' }); + expect(indicators[1]).toHaveStyle({ cursor: 'pointer' }); + }); + + it('should correctly render indicators for "middleware" search', () => { + const extendedRows = [ + ...mockDedupedRows, + { + id: '6', + ServiceName: 'hdx-oss-dev-api', + SpanName: 'middleware - auth', + StatusCode: 'Unset', + }, + { + id: '7', + ServiceName: 'hdx-oss-dev-api', + SpanName: 'middleware - cors', + StatusCode: 'Unset', + }, + ]; + + const extendedTableRows = extendedRows.map((row, index) => ({ + original: row, + index, + })); + + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + expect(indicators.length).toBe(2); + + // Verify titles + expect(indicators[0]).toHaveAttribute('title', 'Match 1 of 2'); + expect(indicators[1]).toHaveAttribute('title', 'Match 2 of 2'); + }); + + it('should correctly render indicator for single "mongodb" match', () => { + const { container } = renderWithMantine( + , + ); + + const indicators = container.querySelectorAll('[title*="Match"]'); + expect(indicators.length).toBe(1); + expect(indicators[0]).toHaveAttribute('title', 'Match 1 of 1'); + expect(indicators[0]).toHaveStyle({ top: '40%' }); // 2 / 5 * 100 + }); + }); +}); diff --git a/packages/app/src/hooks/__tests__/useTableSearch.test.tsx b/packages/app/src/hooks/__tests__/useTableSearch.test.tsx new file mode 100644 index 0000000000..b03c60c7b8 --- /dev/null +++ b/packages/app/src/hooks/__tests__/useTableSearch.test.tsx @@ -0,0 +1,613 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; + +import { useTableSearch } from '../useTableSearch'; + +describe('useTableSearch', () => { + // Realistic test data based on HyperDX logs + const mockRows = [ + { + timestamp: 'Jan 15 4:43:28.966 PM', + ServiceName: 'hdx-oss-dev-app', + StatusCode: 'Unset', + SpanName: 'longtask', + Duration: 69, + }, + { + timestamp: 'Jan 15 4:43:28.912 PM', + ServiceName: 'hdx-oss-dev-app', + StatusCode: 'Unset', + SpanName: 'longtask', + Duration: 54, + }, + { + timestamp: 'Jan 15 4:43:25.034 PM', + ServiceName: 'hdx-oss-dev-api', + StatusCode: 'Unset', + SpanName: 'mongodb.update', + Duration: 1, + }, + { + timestamp: 'Jan 15 4:43:25.021 PM', + ServiceName: 'hdx-oss-dev-api', + StatusCode: 'Unset', + SpanName: 'dns.lookup', + Duration: 0, + }, + { + timestamp: 'Jan 15 4:43:25.021 PM', + ServiceName: 'hdx-oss-dev-api', + StatusCode: 'Unset', + SpanName: 'POST', + Duration: 13, + }, + { + timestamp: 'Jan 15 4:43:25.020 PM', + ServiceName: 'hdx-oss-dev-api', + StatusCode: 'Unset', + SpanName: 'router - /health', + Duration: 0, + }, + { + timestamp: 'Jan 15 4:43:25.020 PM', + ServiceName: 'hdx-oss-dev-api', + StatusCode: 'Unset', + SpanName: 'tcp.connect', + Duration: 1, + }, + { + timestamp: 'Jan 15 4:43:25.020 PM', + ServiceName: 'hdx-oss-dev-api', + StatusCode: 'Unset', + SpanName: 'middleware - isUserAuthenticated', + Duration: 0, + }, + { + timestamp: 'Jan 15 4:43:25.019 PM', + ServiceName: 'hdx-oss-dev-api', + StatusCode: 'Unset', + SpanName: 'middleware - corsMiddleware', + Duration: 0, + }, + ]; + + const searchableColumns = ['ServiceName', 'SpanName', 'StatusCode']; + + describe('initialization', () => { + it('should initialize with empty search state', () => { + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, // No debounce for tests + }), + ); + + expect(result.current.inputValue).toBe(''); + expect(result.current.matchIndices).toEqual([]); + expect(result.current.currentMatchIndex).toBe(0); + expect(result.current.isSearchVisible).toBe(false); + expect(result.current.shouldScrollToMatch).toBe(false); + }); + }); + + describe('search functionality', () => { + it('should find matches for service name "hdx-oss-dev-api"', async () => { + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, + }), + ); + + act(() => { + result.current.handleSearchChange('hdx-oss-dev-api'); + }); + + await waitFor(() => { + expect(result.current.matchIndices).toHaveLength(7); + }); + + expect(result.current.matchIndices).toEqual([2, 3, 4, 5, 6, 7, 8]); + }); + + it('should find matches for span name "mongodb"', async () => { + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, + }), + ); + + act(() => { + result.current.handleSearchChange('mongodb'); + }); + + await waitFor(() => { + expect(result.current.matchIndices).toHaveLength(1); + }); + + expect(result.current.matchIndices).toEqual([2]); + }); + + it('should find matches for "middleware" in span names', async () => { + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, + }), + ); + + act(() => { + result.current.handleSearchChange('middleware'); + }); + + await waitFor(() => { + expect(result.current.matchIndices).toHaveLength(2); + }); + + expect(result.current.matchIndices).toEqual([7, 8]); + }); + + it('should find matches for "longtask" in span names', async () => { + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, + }), + ); + + act(() => { + result.current.handleSearchChange('longtask'); + }); + + await waitFor(() => { + expect(result.current.matchIndices).toHaveLength(2); + }); + + expect(result.current.matchIndices).toEqual([0, 1]); + }); + + it('should perform case-insensitive search', async () => { + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, + }), + ); + + act(() => { + result.current.handleSearchChange('MONGODB'); + }); + + await waitFor(() => { + expect(result.current.matchIndices).toHaveLength(1); + }); + }); + + it('should find partial matches', async () => { + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, + }), + ); + + act(() => { + result.current.handleSearchChange('dns'); + }); + + await waitFor(() => { + expect(result.current.matchIndices).toHaveLength(1); + }); + + expect(result.current.matchIndices).toEqual([3]); + }); + + it('should return no matches for non-existent text', async () => { + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, + }), + ); + + act(() => { + result.current.handleSearchChange('nonexistent'); + }); + + await waitFor(() => { + expect(result.current.matchIndices).toHaveLength(0); + }); + }); + + it('should search across multiple columns', async () => { + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, + }), + ); + + act(() => { + result.current.handleSearchChange('Unset'); + }); + + await waitFor(() => { + // All rows have StatusCode: 'Unset' + expect(result.current.matchIndices).toHaveLength(9); + }); + }); + + it('should clear matches when search is cleared', async () => { + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, + }), + ); + + act(() => { + result.current.handleSearchChange('mongodb'); + }); + + await waitFor(() => { + expect(result.current.matchIndices).toHaveLength(1); + }); + + act(() => { + result.current.handleSearchChange(''); + }); + + await waitFor(() => { + expect(result.current.matchIndices).toEqual([]); + expect(result.current.currentMatchIndex).toBe(0); + }); + }); + + it('should handle rows with null or undefined values', async () => { + const rowsWithNulls = [ + ...mockRows, + { + timestamp: 'Jan 15 4:43:25.000 PM', + ServiceName: null, + StatusCode: undefined, + SpanName: 'test', + Duration: 0, + }, + ]; + + const { result } = renderHook(() => + useTableSearch({ + rows: rowsWithNulls, + searchableColumns, + debounceMs: 0, + }), + ); + + act(() => { + result.current.handleSearchChange('test'); + }); + + await waitFor(() => { + expect(result.current.matchIndices).toHaveLength(1); + }); + + expect(result.current.matchIndices).toEqual([9]); + }); + }); + + describe('navigation between matches', () => { + it('should navigate to next match', async () => { + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, + }), + ); + + act(() => { + result.current.handleSearchChange('middleware'); + }); + + await waitFor(() => { + expect(result.current.matchIndices).toHaveLength(2); + }); + + act(() => { + result.current.handleNextMatch(); + }); + + expect(result.current.currentMatchIndex).toBe(1); + expect(result.current.shouldScrollToMatch).toBe(true); + }); + + it('should wrap to first match when navigating past the last match', async () => { + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, + }), + ); + + act(() => { + result.current.handleSearchChange('middleware'); + }); + + await waitFor(() => { + expect(result.current.matchIndices).toHaveLength(2); + }); + + // Navigate to last match + act(() => { + result.current.handleNextMatch(); + }); + + expect(result.current.currentMatchIndex).toBe(1); + + // Navigate past last match should wrap to first + act(() => { + result.current.handleNextMatch(); + }); + + expect(result.current.currentMatchIndex).toBe(0); + }); + + it('should navigate to previous match', async () => { + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, + }), + ); + + act(() => { + result.current.handleSearchChange('middleware'); + }); + + await waitFor(() => { + expect(result.current.matchIndices).toHaveLength(2); + }); + + // Navigate to second match + act(() => { + result.current.handleNextMatch(); + }); + + expect(result.current.currentMatchIndex).toBe(1); + + // Navigate back to first match + act(() => { + result.current.handlePreviousMatch(); + }); + + expect(result.current.currentMatchIndex).toBe(0); + }); + + it('should wrap to last match when navigating before the first match', async () => { + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, + }), + ); + + act(() => { + result.current.handleSearchChange('middleware'); + }); + + await waitFor(() => { + expect(result.current.matchIndices).toHaveLength(2); + expect(result.current.currentMatchIndex).toBe(0); + }); + + // Navigate before first match should wrap to last + act(() => { + result.current.handlePreviousMatch(); + }); + + expect(result.current.currentMatchIndex).toBe(1); + }); + + it('should handle match click to specific index', async () => { + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, + }), + ); + + act(() => { + result.current.handleSearchChange('hdx-oss-dev-api'); + }); + + await waitFor(() => { + expect(result.current.matchIndices).toHaveLength(7); + }); + + act(() => { + result.current.handleMatchClick(3); + }); + + expect(result.current.currentMatchIndex).toBe(3); + expect(result.current.shouldScrollToMatch).toBe(true); + }); + + it('should set shouldScrollToMatch flag when performing search and navigating', async () => { + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, + }), + ); + + act(() => { + result.current.handleSearchChange('middleware'); + }); + + await waitFor(() => { + expect(result.current.matchIndices).toHaveLength(2); + }); + + // Initial search sets the flag to true + expect(result.current.shouldScrollToMatch).toBe(true); + + // Navigate to next match - flag should still be true + act(() => { + result.current.handleNextMatch(); + }); + + expect(result.current.shouldScrollToMatch).toBe(true); + expect(result.current.currentMatchIndex).toBe(1); + }); + }); + + describe('visibility management', () => { + it('should toggle search visibility', () => { + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, + }), + ); + + expect(result.current.isSearchVisible).toBe(false); + + act(() => { + result.current.setIsSearchVisible(true); + }); + + expect(result.current.isSearchVisible).toBe(true); + + act(() => { + result.current.setIsSearchVisible(false); + }); + + expect(result.current.isSearchVisible).toBe(false); + }); + + it('should call onVisibilityChange callback when visibility changes', () => { + const onVisibilityChange = jest.fn(); + + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, + onVisibilityChange, + }), + ); + + act(() => { + result.current.setIsSearchVisible(true); + }); + + expect(onVisibilityChange).toHaveBeenCalledWith(true); + + act(() => { + result.current.setIsSearchVisible(false); + }); + + expect(onVisibilityChange).toHaveBeenCalledWith(false); + }); + }); + + describe('resetSearch', () => { + it('should reset all search state', async () => { + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, + }), + ); + + // Set up search state + act(() => { + result.current.setIsSearchVisible(true); + result.current.handleSearchChange('mongodb'); + }); + + await waitFor(() => { + expect(result.current.matchIndices).toHaveLength(1); + }); + + // Reset search + act(() => { + result.current.resetSearch(); + }); + + expect(result.current.inputValue).toBe(''); + expect(result.current.matchIndices).toEqual([]); + expect(result.current.currentMatchIndex).toBe(0); + expect(result.current.shouldScrollToMatch).toBe(false); + expect(result.current.isSearchVisible).toBe(false); + }); + }); + + describe('edge cases', () => { + it('should handle whitespace-only search query', () => { + const { result } = renderHook(() => + useTableSearch({ + rows: mockRows, + searchableColumns, + debounceMs: 0, + }), + ); + + act(() => { + result.current.handleSearchChange(' '); + }); + + expect(result.current.matchIndices).toEqual([]); + }); + + it('should handle special characters in search query', async () => { + const rowsWithSpecialChars = [ + { + ServiceName: 'test.service', + SpanName: 'operation/action', + StatusCode: 'OK', + }, + { + ServiceName: 'another-service', + SpanName: 'route:/api/users', + StatusCode: 'OK', + }, + ]; + + const { result } = renderHook(() => + useTableSearch({ + rows: rowsWithSpecialChars, + searchableColumns, + debounceMs: 0, + }), + ); + + act(() => { + result.current.handleSearchChange('/api/'); + }); + + await waitFor(() => { + expect(result.current.matchIndices).toHaveLength(1); + }); + + expect(result.current.matchIndices).toEqual([1]); + }); + }); +}); diff --git a/packages/app/src/hooks/useTableSearch.ts b/packages/app/src/hooks/useTableSearch.ts new file mode 100644 index 0000000000..40dbca9c78 --- /dev/null +++ b/packages/app/src/hooks/useTableSearch.ts @@ -0,0 +1,165 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useDebouncedValue } from '@mantine/hooks'; + +interface UseTableSearchOptions { + /** + * The rows to search through + */ + rows: Record[]; + /** + * The columns to search in + */ + searchableColumns: string[]; + /** + * Debounce delay in milliseconds + * @default 300 + */ + debounceMs?: number; + /** + * Called when search visibility changes + */ + onVisibilityChange?: (visible: boolean) => void; +} + +interface UseTableSearchReturn { + searchQuery: string; + inputValue: string; + setInputValue: (query: string) => void; + matchIndices: number[]; + currentMatchIndex: number; + isSearchVisible: boolean; + setIsSearchVisible: (visible: boolean) => void; + shouldScrollToMatch: boolean; + clearShouldScrollToMatch: () => void; + handlePreviousMatch: () => void; + handleNextMatch: () => void; + handleMatchClick: (index: number) => void; + handleSearchChange: (value: string) => void; + resetSearch: () => void; +} + +/** + * Custom hook to manage table search functionality with debouncing. + * Handles search state, match tracking, and navigation between matches. + */ +export function useTableSearch({ + rows, + searchableColumns, + debounceMs = 300, + onVisibilityChange, +}: UseTableSearchOptions): UseTableSearchReturn { + const [inputValue, setInputValue] = useState(''); + const [debouncedSearchQuery] = useDebouncedValue(inputValue, debounceMs); + const [currentMatchIndex, setCurrentMatchIndex] = useState(0); + const [matchIndices, setMatchIndices] = useState([]); + const [isSearchVisible, setIsSearchVisible] = useState(false); + const shouldScrollToMatchRef = useRef(false); + + // Search through all rows when debounced query changes + useEffect(() => { + if (!debouncedSearchQuery.trim()) { + setMatchIndices([]); + setCurrentMatchIndex(0); + return; + } + + const query = debouncedSearchQuery.toLowerCase(); + const foundMatches: number[] = []; + + rows.forEach((row, index) => { + // Search through all searchable columns + const rowText = searchableColumns + .map(col => { + const value = (row as Record)[col]; + return value != null ? String(value).toLowerCase() : ''; + }) + .join(' '); + + if (rowText.includes(query)) { + foundMatches.push(index); + } + }); + + const previousMatchCount = matchIndices.length; + setMatchIndices(foundMatches); + + // Only reset to first match if this is a new search (not just loading more data) + // If we had matches before and still have matches, try to stay on the same match + if (previousMatchCount === 0 && foundMatches.length > 0) { + setCurrentMatchIndex(0); + shouldScrollToMatchRef.current = true; + } else if ( + foundMatches.length > 0 && + currentMatchIndex >= foundMatches.length + ) { + // If current match is now out of bounds, go to last match + setCurrentMatchIndex(foundMatches.length - 1); + shouldScrollToMatchRef.current = true; + } + // Otherwise keep the current match index as is (loading more data shouldn't change position) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedSearchQuery, rows, searchableColumns]); + + // Handle visibility changes + useEffect(() => { + onVisibilityChange?.(isSearchVisible); + }, [isSearchVisible, onVisibilityChange]); + + const handleSearchChange = useCallback((value: string) => { + setInputValue(value); + // Reset match state when query is cleared + if (!value) { + setMatchIndices([]); + setCurrentMatchIndex(0); + shouldScrollToMatchRef.current = false; + } + }, []); + + const handlePreviousMatch = useCallback(() => { + shouldScrollToMatchRef.current = true; + setCurrentMatchIndex(prev => + prev > 0 ? prev - 1 : matchIndices.length - 1, + ); + }, [matchIndices.length]); + + const handleNextMatch = useCallback(() => { + shouldScrollToMatchRef.current = true; + setCurrentMatchIndex(prev => + prev < matchIndices.length - 1 ? prev + 1 : 0, + ); + }, [matchIndices.length]); + + const handleMatchClick = useCallback((index: number) => { + shouldScrollToMatchRef.current = true; + setCurrentMatchIndex(index); + }, []); + + const resetSearch = useCallback(() => { + setInputValue(''); + setMatchIndices([]); + setCurrentMatchIndex(0); + shouldScrollToMatchRef.current = false; + setIsSearchVisible(false); + }, []); + + const clearShouldScrollToMatch = useCallback(() => { + shouldScrollToMatchRef.current = false; + }, []); + + return { + searchQuery: debouncedSearchQuery, + inputValue, + setInputValue, + matchIndices, + currentMatchIndex, + isSearchVisible, + setIsSearchVisible, + shouldScrollToMatch: shouldScrollToMatchRef.current, + clearShouldScrollToMatch, + handlePreviousMatch, + handleNextMatch, + handleMatchClick, + handleSearchChange, + resetSearch, + }; +}