Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🔮 [HADXVI-53] Browser SDK extension search bar improvement #2771

Merged
merged 23 commits into from
Jun 19, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions developer-extension/src/panel/components/json.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -310,11 +310,16 @@ function JsonLine({
)
}

function CopyMenuItem({ value, children }: { value: unknown; children: ReactNode }) {
export function CopyMenuItem({ value, children }: { value: unknown; children: ReactNode }) {
return (
<Menu.Item
onClick={() => {
copy(JSON.stringify(value, null, 2))
// Remove the outer quotation marks from copied string
Copy link
Member

Choose a reason for hiding this comment

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

🥜 nitpick: ‏this comment isn't accurate anymore

if (typeof value === 'object') {
copy(JSON.stringify(value, null, 2))
} else {
copy(String(value))
}
}}
leftSection={<IconCopy size={14} />}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type {
import type { SdkEvent } from '../../../sdkEvent'
import { isTelemetryEvent, isLogEvent, isRumEvent } from '../../../sdkEvent'
import { formatDate, formatDuration } from '../../../formatNumber'
import { defaultFormatValue, Json } from '../../json'
import { CopyMenuItem, defaultFormatValue, Json } from '../../json'
import { LazyCollapse } from '../../lazyCollapse'
import type { FacetRegistry } from '../../../hooks/useEvents'
import { useSdkInfos } from '../../../hooks/useSdkInfos'
Expand Down Expand Up @@ -90,6 +90,11 @@ export const EventRow = React.memo(
)
}

function getCopyMenuItemsForPath(path: string, value: unknown) {
const searchTerm = String(value).replace(/ /g, '\\ ')
return <CopyMenuItem value={`${path}:${searchTerm}`}>Copy search query</CopyMenuItem>
}

return (
<Table.Tr>
{columns.map((column): React.ReactElement => {
Expand Down Expand Up @@ -151,7 +156,15 @@ export const EventRow = React.memo(
<Json
value={value}
defaultCollapseLevel={0}
getMenuItemsForPath={(path) => getMenuItemsForPath(path ? `${column.path}.${path}` : column.path)}
getMenuItemsForPath={(path) => {
const fullPath = path ? `${column.path}.${path}` : column.path
return (
<>
{getMenuItemsForPath(fullPath)}
{typeof value === 'string' ? getCopyMenuItemsForPath(column.path, value) : undefined}
Copy link
Member

Choose a reason for hiding this comment

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

💬 suggestion: ‏move this code to getMenuItemsForPath. It could look like this:

 function getMenuItemsForPath(path: string, value: unknown) {
      const menuItems: React.ChildNode[] = []

      const newColumn: EventListColumn = { type: 'field', path }
      if (path && !includesColumn(columns, newColumn)) {
        menuItems.push(
          <Menu.Item
            key="add-column"
            onClick={() => {
              onColumnsChange(addColumn(columns, newColumn))
            }}
            leftSection={<IconColumnInsertRight size={14} />}
          >
            Add column
          </Menu.Item>
        )
      }

      if (typeof value === "string") {
        const searchTerm = String(value).replace(/ /g, '\\ ')
        menuItems.push(
          <CopyMenuItem value={`${path}:${searchTerm}`}>Copy search query</CopyMenuItem>
        )
      }

      return <>{menuItems}</>
    }

</>
)
}}
formatValue={(path, value) => formatValue(path ? `${column.path}.${path}` : column.path, value)}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { parseQuery, matchWithWildcard } from './eventFilters'

describe('parseQuery', () => {
it('return a simple field', () => {
expect(parseQuery('foo:bar')).toEqual([['foo', 'bar']])
})
it('return intermediary fields', () => {
expect(parseQuery('foo.bar:baz')).toEqual([['foo.bar', 'baz']])
})
it('return multiple fields', () => {
expect(parseQuery('foo:bar baz:qux')).toEqual([
['foo', 'bar'],
['baz', 'qux'],
])
})
it('parse escaped whitespace with backslashes in search terms', () => {
expect(parseQuery('foo:bar\\ baz')).toEqual([['foo', 'bar\\ baz']])
})
it('parse escaped whitespace with backslashes in keys', () => {
expect(parseQuery('foo\\ bar:baz')).toEqual([['foo\\ bar', 'baz']])
})
it('return multiple fields with escaped whitespace', () => {
expect(parseQuery('foo\\ bar:baz\\ qux')).toEqual([['foo\\ bar', 'baz\\ qux']])
expect(parseQuery('foo:bar\\ baz qux:quux\\ corge')).toEqual([
['foo', 'bar\\ baz'],
['qux', 'quux\\ corge'],
])
})
})

describe('matchWithWildcard', () => {
it('matches exact strings', () => {
expect(matchWithWildcard('foo', 'foo')).toBe(true)
})
it('matches exact strings case-insensitively', () => {
expect(matchWithWildcard('foo', 'FOO')).toBe(true)
})
it('matches substrings', () => {
expect(matchWithWildcard('foo', 'oo')).toBe(true)
})
it('matches substrings case-insensitively', () => {
expect(matchWithWildcard('foo', 'OO')).toBe(true)
})
it('does not match missing substrings', () => {
expect(matchWithWildcard('foo', 'bar')).toBe(false)
})
it('does not match missing substrings case-insensitively', () => {
expect(matchWithWildcard('foo', 'BAR')).toBe(false)
})
it('matches with wildcard at the beginning', () => {
expect(matchWithWildcard('foo', '*oo')).toBe(true)
})
it('matches with wildcard at the end', () => {
expect(matchWithWildcard('foo', 'fo*')).toBe(true)
})
it('matches with wildcard at the beginning and the end', () => {
expect(matchWithWildcard('foo', '*o*')).toBe(true)
})
it('matches with wildcard at the beginning and the end case-insensitively', () => {
expect(matchWithWildcard('foo', '*O*')).toBe(true)
})
it('does not match missing substrings with wildcard at the beginning', () => {
expect(matchWithWildcard('foo', '*bar')).toBe(false)
})
it('does not match missing substrings with wildcard at the end', () => {
expect(matchWithWildcard('foo', 'bar*')).toBe(false)
})
it('does not match missing substrings with wildcard at the beginning and the end', () => {
expect(matchWithWildcard('foo', '*bar*')).toBe(false)
})
it('does not match missing substrings with wildcard at the beginning and the end case-insensitively', () => {
expect(matchWithWildcard('foo', '*BAR*')).toBe(false)
})
})
40 changes: 32 additions & 8 deletions developer-extension/src/panel/hooks/useEvents/eventFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,14 @@ export function applyEventFilters(filters: EventFilters, events: SdkEvent[], fac
filteredEvents = filterExcludedFacets(filteredEvents, filters.excludedFacetValues, facetRegistry)

if (filters.query) {
const query = parseQuery(filters.query)
filteredEvents = filteredEvents.filter(query.match)
const queryParts: string[][] = parseQuery(filters.query)
const matchQuery = (event: SdkEvent) =>
queryParts.every((queryPart) => {
// Hack it to restore the whitespace
const searchTerm = queryPart.length > 1 ? queryPart[1].replaceAll(/\\\s+/gm, ' ') : ''
return matchQueryPart(event, queryPart[0], searchTerm)
})
filteredEvents = filteredEvents.filter(matchQuery)
}

if (!filters.outdatedVersions) {
Expand Down Expand Up @@ -73,20 +79,38 @@ function filterOutdatedVersions(events: SdkEvent[]): SdkEvent[] {
return events.filter((event) => !outdatedEvents.has(event))
}

function parseQuery(query: string) {
export function parseQuery(query: string) {
const queryParts = query
.split(' ')
.split(/(?<!\\)\s/gm) // Hack it to escape whitespace with backslashes
Copy link
Collaborator

Choose a reason for hiding this comment

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

👏 praise: ‏ nice!

.filter((queryPart) => queryPart)
.map((queryPart) => queryPart.split(':'))

return {
match: (event: SdkEvent) =>
queryParts.every((queryPart) => matchQueryPart(event, queryPart[0], queryPart.length ? queryPart[1] : '')),
return queryParts
}

export function matchWithWildcard(value: string, searchTerm: string): boolean {
value = value.toLowerCase()
searchTerm = searchTerm.toLowerCase()
if (!searchTerm.includes('*')) {
return value.includes(searchTerm)
}
const searchTerms = searchTerm.toLowerCase().split('*')
let lastIndex = 0
for (const term of searchTerms) {
const index = value.indexOf(term, lastIndex)
if (index === -1) {
return false
}
lastIndex = index + term.length
}
return true
}

function matchQueryPart(json: unknown, searchKey: string, searchTerm: string, jsonPath = ''): boolean {
if (jsonPath.endsWith(searchKey) && String(json).startsWith(searchTerm)) {
if (searchKey.toLowerCase() === 'description') {
return matchWithWildcard(JSON.stringify(json), searchTerm)
}
if (jsonPath.endsWith(searchKey) && matchWithWildcard(String(json), searchTerm)) {
return true
}

Expand Down