Skip to content

Commit

Permalink
Replace search engine and improve UX (#337)
Browse files Browse the repository at this point in the history
  • Loading branch information
davidjgoss authored Dec 9, 2023
1 parent abdc29b commit 405c88d
Show file tree
Hide file tree
Showing 33 changed files with 708 additions and 9,739 deletions.
3 changes: 1 addition & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,4 @@ jobs:
- run: npm test
- run: npm run eslint
- run: npm run compile
# not running ladle build due to elasticlunr bundling issue
# - run: npm run build
- run: npm run build
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## [Unreleased]
### Changed
- BREAKING CHANGE: Switch to ESM ([#338](https://github.com/cucumber/react-components/pull/338))
- Switch search implementation to Orama ([#337](https://github.com/cucumber/react-components/pull/337))
- Apply search query on change ([#337](https://github.com/cucumber/react-components/pull/337))

## [21.1.1] - 2023-07-13
### Fixed
Expand Down
9,190 changes: 23 additions & 9,167 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,12 @@
"@fortawesome/fontawesome-svg-core": "6.2.1",
"@fortawesome/free-solid-svg-icons": "6.2.1",
"@fortawesome/react-fontawesome": "0.2.0",
"@orama/orama": "^2.0.0-beta.7",
"@orama/stemmers": "^2.0.0-beta.7",
"@teppeis/multimaps": "2.0.0",
"@types/elasticlunr": "0.9.5",
"ansi-to-html": "0.7.2",
"color": "4.2.3",
"date-fns": "2.29.3",
"elasticlunr": "0.9.5",
"hast-util-sanitize": "^5.0.1",
"highlight-words": "1.2.2",
"mime": "^3.0.0",
Expand All @@ -48,7 +47,8 @@
"rehype-raw": "5.1.0",
"rehype-sanitize": "4.0.0",
"remark-breaks": "2.0.2",
"remark-gfm": "1.0.0"
"remark-gfm": "1.0.0",
"use-debounce": "^10.0.0"
},
"peerDependencies": {
"react": "~18",
Expand Down
134 changes: 76 additions & 58 deletions src/components/app/FilteredResults.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Envelope } from '@cucumber/messages'
import { render } from '@testing-library/react'
import { render, waitFor } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import { expect } from 'chai'
import React, { VoidFunctionComponent } from 'react'
Expand All @@ -26,26 +26,28 @@ describe('FilteredResults', () => {
}

describe('with a targeted run', () => {
it('doesnt include features where no scenarios became test cases', () => {
it('doesnt include features where no scenarios became test cases', async () => {
const { getByRole, queryByRole } = render(
<TestableFilteredResults envelopes={targetedRun as Envelope[]} />
)

expect(
getByRole('heading', {
name: 'features/adding.feature',
})
).to.be.visible
expect(
queryByRole('heading', {
name: 'features/editing.feature',
})
).not.to.exist
expect(
queryByRole('heading', {
name: 'features/empty.feature',
})
).not.to.exist
await waitFor(() => {
expect(
getByRole('heading', {
name: 'features/adding.feature',
})
).to.be.visible
expect(
queryByRole('heading', {
name: 'features/editing.feature',
})
).not.to.exist
expect(
queryByRole('heading', {
name: 'features/empty.feature',
})
).not.to.exist
})
})
})

Expand All @@ -55,10 +57,14 @@ describe('FilteredResults', () => {
<TestableFilteredResults envelopes={attachments as Envelope[]} />
)

await waitFor(() => getByText('samples/attachments/attachments.feature'))

await userEvent.type(getByRole('textbox', { name: 'Search' }), 'nope!')
await userEvent.keyboard('{Enter}')

expect(getByText('No matches found for your query "nope!" and/or filters')).to.be.visible
await waitFor(() => {
expect(getByText('No matches found for your query "nope!" and/or filters')).to.be.visible
})
})

it('narrows the results with a valid search term, and restores when we clear the search', async () => {
Expand All @@ -84,64 +90,76 @@ describe('FilteredResults', () => {
})

describe('filtering by status', () => {
it('should not show filters when only one status', () => {
it('should not show filters when only one status', async () => {
const { queryByRole } = render(<TestableFilteredResults envelopes={minimal as Envelope[]} />)

expect(queryByRole('checkbox')).not.to.exist
await waitFor(() => {
expect(queryByRole('checkbox')).not.to.exist
})
})

it('should show named status filters, all checked by default', () => {
it('should show named status filters, all checked by default', async () => {
const { getAllByRole, getByRole } = render(
<TestableFilteredResults envelopes={examplesTables as Envelope[]} />
)

expect(getAllByRole('checkbox')).to.have.length(3)
expect(getByRole('checkbox', { name: 'passed' })).to.be.visible
expect(getByRole('checkbox', { name: 'failed' })).to.be.visible
expect(getByRole('checkbox', { name: 'undefined' })).to.be.visible
getAllByRole('checkbox').forEach((checkbox: HTMLInputElement) => {
expect(checkbox).to.be.checked
await waitFor(() => {
expect(getAllByRole('checkbox')).to.have.length(3)
expect(getByRole('checkbox', { name: 'passed' })).to.be.visible
expect(getByRole('checkbox', { name: 'failed' })).to.be.visible
expect(getByRole('checkbox', { name: 'undefined' })).to.be.visible
getAllByRole('checkbox').forEach((checkbox: HTMLInputElement) => {
expect(checkbox).to.be.checked
})
})
})

it('should hide features with a certain status when we uncheck it', async () => {
const { getByRole, queryByRole } = render(
<TestableFilteredResults envelopes={[...examplesTables, ...minimal] as Envelope[]} />
)
it('should hide features with a certain status when we uncheck it', async () => {
const { getByRole, queryByRole } = render(
<TestableFilteredResults envelopes={[...examplesTables, ...minimal] as Envelope[]} />
)

expect(getByRole('heading', { name: 'samples/examples-tables/examples-tables.feature' })).to
.be.visible
expect(getByRole('heading', { name: 'samples/minimal/minimal.feature' })).to.be.visible
await waitFor(() => {
expect(getByRole('heading', { name: 'samples/examples-tables/examples-tables.feature' }))
.to.be.visible
expect(getByRole('heading', { name: 'samples/minimal/minimal.feature' })).to.be.visible
})

await userEvent.click(getByRole('checkbox', { name: 'passed' }))
await userEvent.click(getByRole('checkbox', { name: 'passed' }))

expect(getByRole('heading', { name: 'samples/examples-tables/examples-tables.feature' })).to
.be.visible
expect(
queryByRole('heading', {
name: 'samples/minimal/minimal.feature',
await waitFor(() => {
expect(getByRole('heading', { name: 'samples/examples-tables/examples-tables.feature' }))
.to.be.visible
expect(
queryByRole('heading', {
name: 'samples/minimal/minimal.feature',
})
).not.to.exist
})
).not.to.exist
})

it('should show a message if we filter all statuses out', async () => {
const { getByRole, queryByRole, getByText } = render(
<TestableFilteredResults envelopes={examplesTables as Envelope[]} />
)
})

expect(getByRole('heading', { name: 'samples/examples-tables/examples-tables.feature' })).to
.be.visible
it('should show a message if we filter all statuses out', async () => {
const { getByRole, queryByRole, getByText } = render(
<TestableFilteredResults envelopes={examplesTables as Envelope[]} />
)

await userEvent.click(getByRole('checkbox', { name: 'passed' }))
await userEvent.click(getByRole('checkbox', { name: 'failed' }))
await userEvent.click(getByRole('checkbox', { name: 'undefined' }))
await waitFor(() => {
expect(getByRole('heading', { name: 'samples/examples-tables/examples-tables.feature' }))
.to.be.visible
})

expect(
queryByRole('heading', {
name: 'samples/examples-tables/examples-tables.feature',
await userEvent.click(getByRole('checkbox', { name: 'passed' }))
await userEvent.click(getByRole('checkbox', { name: 'failed' }))
await userEvent.click(getByRole('checkbox', { name: 'undefined' }))

await waitFor(() => {
expect(
queryByRole('heading', {
name: 'samples/examples-tables/examples-tables.feature',
})
).not.to.exist
expect(getByText('No matches found for your filters')).to.be.visible
})
).not.to.exist
expect(getByText('No matches found for your filters')).to.be.visible
})
})
})
})
41 changes: 16 additions & 25 deletions src/components/app/FilteredResults.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { GherkinDocument } from '@cucumber/messages'
import React from 'react'

import countScenariosByStatuses from '../../countScenariosByStatuses.js'
import filterByStatus from '../../filter/filterByStatus.js'
import { useQueries, useSearch } from '../../hooks/index.js'
import Search from '../../search/Search.js'
import { useFilteredDocuments } from '../../hooks/useFilteredDocuments.js'
import { useResultStatistics } from '../../hooks/useResultStatistics.js'
import { ExecutionSummary } from './ExecutionSummary.js'
import styles from './FilteredResults.module.scss'
import { GherkinDocumentList } from './GherkinDocumentList.js'
Expand All @@ -17,24 +15,10 @@ interface IProps {
}

export const FilteredResults: React.FunctionComponent<IProps> = ({ className }) => {
const { cucumberQuery, gherkinQuery, envelopesQuery } = useQueries()
const { envelopesQuery } = useQueries()
const { scenarioCountByStatus, statusesWithScenarios, totalScenarioCount } = useResultStatistics()
const { query, hideStatuses, update } = useSearch()
const allDocuments = gherkinQuery.getGherkinDocuments()

const { scenarioCountByStatus, statusesWithScenarios, totalScenarioCount } =
countScenariosByStatuses(gherkinQuery, cucumberQuery, envelopesQuery)

const search = new Search(gherkinQuery)
for (const gherkinDocument of allDocuments) {
search.add(gherkinDocument)
}

const onlyShowStatuses = statusesWithScenarios.filter((s) => !hideStatuses.includes(s))

const matches = query ? search.search(query) : allDocuments
const filtered = matches
.map((document) => filterByStatus(document, gherkinQuery, cucumberQuery, onlyShowStatuses))
.filter((document) => document !== null) as GherkinDocument[]
const filtered = useFilteredDocuments(query, hideStatuses)

return (
<div className={className}>
Expand All @@ -52,15 +36,22 @@ export const FilteredResults: React.FunctionComponent<IProps> = ({ className })
/>
<SearchBar
query={query}
onSearch={(query) => update({ query })}
onSearch={(newValue) => update({ query: newValue })}
statusesWithScenarios={statusesWithScenarios}
hideStatuses={hideStatuses}
onFilter={(hideStatuses) => update({ hideStatuses })}
onFilter={(newValue) => update({ hideStatuses: newValue })}
/>
</div>

{filtered.length > 0 && <GherkinDocumentList gherkinDocuments={filtered} preExpand={true} />}
{filtered.length < 1 && <NoMatchResult query={query} />}
{filtered !== undefined && (
<>
{filtered.length > 0 ? (
<GherkinDocumentList gherkinDocuments={filtered} preExpand={true} />
) : (
<NoMatchResult query={query} />
)}
</>
)}
</div>
)
}
4 changes: 2 additions & 2 deletions src/components/app/HighLight.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import elasticlunr from 'elasticlunr'
import { stemmer } from '@orama/stemmers/english'
import highlightWords from 'highlight-words'
import React from 'react'
import ReactMarkdown from 'react-markdown'
Expand All @@ -15,7 +15,7 @@ interface IProps {

const allQueryWords = (queryWords: string[]): string[] => {
return queryWords.reduce((allWords, word) => {
const stem = elasticlunr.stemmer(word)
const stem = stemmer(word)
allWords.push(word)

if (stem !== word) {
Expand Down
21 changes: 21 additions & 0 deletions src/components/app/SearchBar.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,27 @@ describe('SearchBar', () => {
expect(getByRole('textbox', { name: 'Search' })).to.have.value('keyword')
})

it('fires an event after half a second when the user types a query', async () => {
const onChange = sinon.fake()
const { getByRole } = render(
<SearchBar
query={''}
onSearch={onChange}
hideStatuses={[]}
statusesWithScenarios={[]}
onFilter={sinon.fake()}
/>
)

await userEvent.type(getByRole('textbox', { name: 'Search' }), 'search text')

expect(onChange).not.to.have.been.called

await new Promise((resolve) => setTimeout(resolve, 500))

expect(onChange).to.have.been.called
})

it('fires an event with the query when the form is submitted', async () => {
const onChange = sinon.fake()
const { getByRole } = render(
Expand Down
9 changes: 6 additions & 3 deletions src/components/app/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { TestStepResultStatus as Status } from '@cucumber/messages'
import { faFilter, faSearch } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import React, { FunctionComponent } from 'react'
import { useDebouncedCallback } from 'use-debounce'

import statusName from '../gherkin/statusName.js'
import styles from './SearchBar.module.scss'
Expand All @@ -22,11 +23,12 @@ export const SearchBar: FunctionComponent<IProps> = ({
onSearch,
onFilter,
}) => {
const debouncedSearchChange = useDebouncedCallback((newValue) => {
onSearch(newValue)
}, 500)
const searchSubmitted = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
const formData = new window.FormData(event.currentTarget)
const query = formData.get('query')
onSearch((query || '').toString())
debouncedSearchChange.flush()
}
const filterChanged = (name: Status, show: boolean) => {
onFilter(show ? hideStatuses.filter((s) => s !== name) : hideStatuses.concat(name))
Expand All @@ -42,6 +44,7 @@ export const SearchBar: FunctionComponent<IProps> = ({
name="query"
placeholder="Search with text or @tags"
defaultValue={query}
onChange={(e) => debouncedSearchChange(e.target.value)}
/>
<small className={styles.searchHelp}>
You can search with plain text or{' '}
Expand Down
Loading

0 comments on commit 405c88d

Please sign in to comment.