Skip to content

Commit 405c88d

Browse files
authored
Replace search engine and improve UX (#337)
1 parent abdc29b commit 405c88d

33 files changed

+708
-9739
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,4 @@ jobs:
2929
- run: npm test
3030
- run: npm run eslint
3131
- run: npm run compile
32-
# not running ladle build due to elasticlunr bundling issue
33-
# - run: npm run build
32+
- run: npm run build

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
88
## [Unreleased]
99
### Changed
1010
- BREAKING CHANGE: Switch to ESM ([#338](https://github.com/cucumber/react-components/pull/338))
11+
- Switch search implementation to Orama ([#337](https://github.com/cucumber/react-components/pull/337))
12+
- Apply search query on change ([#337](https://github.com/cucumber/react-components/pull/337))
1113

1214
## [21.1.1] - 2023-07-13
1315
### Fixed

package-lock.json

Lines changed: 23 additions & 9167 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,12 @@
3333
"@fortawesome/fontawesome-svg-core": "6.2.1",
3434
"@fortawesome/free-solid-svg-icons": "6.2.1",
3535
"@fortawesome/react-fontawesome": "0.2.0",
36+
"@orama/orama": "^2.0.0-beta.7",
3637
"@orama/stemmers": "^2.0.0-beta.7",
3738
"@teppeis/multimaps": "2.0.0",
38-
"@types/elasticlunr": "0.9.5",
3939
"ansi-to-html": "0.7.2",
4040
"color": "4.2.3",
4141
"date-fns": "2.29.3",
42-
"elasticlunr": "0.9.5",
4342
"hast-util-sanitize": "^5.0.1",
4443
"highlight-words": "1.2.2",
4544
"mime": "^3.0.0",
@@ -48,7 +47,8 @@
4847
"rehype-raw": "5.1.0",
4948
"rehype-sanitize": "4.0.0",
5049
"remark-breaks": "2.0.2",
51-
"remark-gfm": "1.0.0"
50+
"remark-gfm": "1.0.0",
51+
"use-debounce": "^10.0.0"
5252
},
5353
"peerDependencies": {
5454
"react": "~18",
Lines changed: 76 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Envelope } from '@cucumber/messages'
2-
import { render } from '@testing-library/react'
2+
import { render, waitFor } from '@testing-library/react'
33
import { userEvent } from '@testing-library/user-event'
44
import { expect } from 'chai'
55
import React, { VoidFunctionComponent } from 'react'
@@ -26,26 +26,28 @@ describe('FilteredResults', () => {
2626
}
2727

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

34-
expect(
35-
getByRole('heading', {
36-
name: 'features/adding.feature',
37-
})
38-
).to.be.visible
39-
expect(
40-
queryByRole('heading', {
41-
name: 'features/editing.feature',
42-
})
43-
).not.to.exist
44-
expect(
45-
queryByRole('heading', {
46-
name: 'features/empty.feature',
47-
})
48-
).not.to.exist
34+
await waitFor(() => {
35+
expect(
36+
getByRole('heading', {
37+
name: 'features/adding.feature',
38+
})
39+
).to.be.visible
40+
expect(
41+
queryByRole('heading', {
42+
name: 'features/editing.feature',
43+
})
44+
).not.to.exist
45+
expect(
46+
queryByRole('heading', {
47+
name: 'features/empty.feature',
48+
})
49+
).not.to.exist
50+
})
4951
})
5052
})
5153

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

60+
await waitFor(() => getByText('samples/attachments/attachments.feature'))
61+
5862
await userEvent.type(getByRole('textbox', { name: 'Search' }), 'nope!')
5963
await userEvent.keyboard('{Enter}')
6064

61-
expect(getByText('No matches found for your query "nope!" and/or filters')).to.be.visible
65+
await waitFor(() => {
66+
expect(getByText('No matches found for your query "nope!" and/or filters')).to.be.visible
67+
})
6268
})
6369

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

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

90-
expect(queryByRole('checkbox')).not.to.exist
96+
await waitFor(() => {
97+
expect(queryByRole('checkbox')).not.to.exist
98+
})
9199
})
92100

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

98-
expect(getAllByRole('checkbox')).to.have.length(3)
99-
expect(getByRole('checkbox', { name: 'passed' })).to.be.visible
100-
expect(getByRole('checkbox', { name: 'failed' })).to.be.visible
101-
expect(getByRole('checkbox', { name: 'undefined' })).to.be.visible
102-
getAllByRole('checkbox').forEach((checkbox: HTMLInputElement) => {
103-
expect(checkbox).to.be.checked
106+
await waitFor(() => {
107+
expect(getAllByRole('checkbox')).to.have.length(3)
108+
expect(getByRole('checkbox', { name: 'passed' })).to.be.visible
109+
expect(getByRole('checkbox', { name: 'failed' })).to.be.visible
110+
expect(getByRole('checkbox', { name: 'undefined' })).to.be.visible
111+
getAllByRole('checkbox').forEach((checkbox: HTMLInputElement) => {
112+
expect(checkbox).to.be.checked
113+
})
104114
})
105-
})
106115

107-
it('should hide features with a certain status when we uncheck it', async () => {
108-
const { getByRole, queryByRole } = render(
109-
<TestableFilteredResults envelopes={[...examplesTables, ...minimal] as Envelope[]} />
110-
)
116+
it('should hide features with a certain status when we uncheck it', async () => {
117+
const { getByRole, queryByRole } = render(
118+
<TestableFilteredResults envelopes={[...examplesTables, ...minimal] as Envelope[]} />
119+
)
111120

112-
expect(getByRole('heading', { name: 'samples/examples-tables/examples-tables.feature' })).to
113-
.be.visible
114-
expect(getByRole('heading', { name: 'samples/minimal/minimal.feature' })).to.be.visible
121+
await waitFor(() => {
122+
expect(getByRole('heading', { name: 'samples/examples-tables/examples-tables.feature' }))
123+
.to.be.visible
124+
expect(getByRole('heading', { name: 'samples/minimal/minimal.feature' })).to.be.visible
125+
})
115126

116-
await userEvent.click(getByRole('checkbox', { name: 'passed' }))
127+
await userEvent.click(getByRole('checkbox', { name: 'passed' }))
117128

118-
expect(getByRole('heading', { name: 'samples/examples-tables/examples-tables.feature' })).to
119-
.be.visible
120-
expect(
121-
queryByRole('heading', {
122-
name: 'samples/minimal/minimal.feature',
129+
await waitFor(() => {
130+
expect(getByRole('heading', { name: 'samples/examples-tables/examples-tables.feature' }))
131+
.to.be.visible
132+
expect(
133+
queryByRole('heading', {
134+
name: 'samples/minimal/minimal.feature',
135+
})
136+
).not.to.exist
123137
})
124-
).not.to.exist
125-
})
126-
127-
it('should show a message if we filter all statuses out', async () => {
128-
const { getByRole, queryByRole, getByText } = render(
129-
<TestableFilteredResults envelopes={examplesTables as Envelope[]} />
130-
)
138+
})
131139

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

135-
await userEvent.click(getByRole('checkbox', { name: 'passed' }))
136-
await userEvent.click(getByRole('checkbox', { name: 'failed' }))
137-
await userEvent.click(getByRole('checkbox', { name: 'undefined' }))
145+
await waitFor(() => {
146+
expect(getByRole('heading', { name: 'samples/examples-tables/examples-tables.feature' }))
147+
.to.be.visible
148+
})
138149

139-
expect(
140-
queryByRole('heading', {
141-
name: 'samples/examples-tables/examples-tables.feature',
150+
await userEvent.click(getByRole('checkbox', { name: 'passed' }))
151+
await userEvent.click(getByRole('checkbox', { name: 'failed' }))
152+
await userEvent.click(getByRole('checkbox', { name: 'undefined' }))
153+
154+
await waitFor(() => {
155+
expect(
156+
queryByRole('heading', {
157+
name: 'samples/examples-tables/examples-tables.feature',
158+
})
159+
).not.to.exist
160+
expect(getByText('No matches found for your filters')).to.be.visible
142161
})
143-
).not.to.exist
144-
expect(getByText('No matches found for your filters')).to.be.visible
162+
})
145163
})
146164
})
147165
})
Lines changed: 16 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1-
import { GherkinDocument } from '@cucumber/messages'
21
import React from 'react'
32

4-
import countScenariosByStatuses from '../../countScenariosByStatuses.js'
5-
import filterByStatus from '../../filter/filterByStatus.js'
63
import { useQueries, useSearch } from '../../hooks/index.js'
7-
import Search from '../../search/Search.js'
4+
import { useFilteredDocuments } from '../../hooks/useFilteredDocuments.js'
5+
import { useResultStatistics } from '../../hooks/useResultStatistics.js'
86
import { ExecutionSummary } from './ExecutionSummary.js'
97
import styles from './FilteredResults.module.scss'
108
import { GherkinDocumentList } from './GherkinDocumentList.js'
@@ -17,24 +15,10 @@ interface IProps {
1715
}
1816

1917
export const FilteredResults: React.FunctionComponent<IProps> = ({ className }) => {
20-
const { cucumberQuery, gherkinQuery, envelopesQuery } = useQueries()
18+
const { envelopesQuery } = useQueries()
19+
const { scenarioCountByStatus, statusesWithScenarios, totalScenarioCount } = useResultStatistics()
2120
const { query, hideStatuses, update } = useSearch()
22-
const allDocuments = gherkinQuery.getGherkinDocuments()
23-
24-
const { scenarioCountByStatus, statusesWithScenarios, totalScenarioCount } =
25-
countScenariosByStatuses(gherkinQuery, cucumberQuery, envelopesQuery)
26-
27-
const search = new Search(gherkinQuery)
28-
for (const gherkinDocument of allDocuments) {
29-
search.add(gherkinDocument)
30-
}
31-
32-
const onlyShowStatuses = statusesWithScenarios.filter((s) => !hideStatuses.includes(s))
33-
34-
const matches = query ? search.search(query) : allDocuments
35-
const filtered = matches
36-
.map((document) => filterByStatus(document, gherkinQuery, cucumberQuery, onlyShowStatuses))
37-
.filter((document) => document !== null) as GherkinDocument[]
21+
const filtered = useFilteredDocuments(query, hideStatuses)
3822

3923
return (
4024
<div className={className}>
@@ -52,15 +36,22 @@ export const FilteredResults: React.FunctionComponent<IProps> = ({ className })
5236
/>
5337
<SearchBar
5438
query={query}
55-
onSearch={(query) => update({ query })}
39+
onSearch={(newValue) => update({ query: newValue })}
5640
statusesWithScenarios={statusesWithScenarios}
5741
hideStatuses={hideStatuses}
58-
onFilter={(hideStatuses) => update({ hideStatuses })}
42+
onFilter={(newValue) => update({ hideStatuses: newValue })}
5943
/>
6044
</div>
6145

62-
{filtered.length > 0 && <GherkinDocumentList gherkinDocuments={filtered} preExpand={true} />}
63-
{filtered.length < 1 && <NoMatchResult query={query} />}
46+
{filtered !== undefined && (
47+
<>
48+
{filtered.length > 0 ? (
49+
<GherkinDocumentList gherkinDocuments={filtered} preExpand={true} />
50+
) : (
51+
<NoMatchResult query={query} />
52+
)}
53+
</>
54+
)}
6455
</div>
6556
)
6657
}

src/components/app/HighLight.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import elasticlunr from 'elasticlunr'
1+
import { stemmer } from '@orama/stemmers/english'
22
import highlightWords from 'highlight-words'
33
import React from 'react'
44
import ReactMarkdown from 'react-markdown'
@@ -15,7 +15,7 @@ interface IProps {
1515

1616
const allQueryWords = (queryWords: string[]): string[] => {
1717
return queryWords.reduce((allWords, word) => {
18-
const stem = elasticlunr.stemmer(word)
18+
const stem = stemmer(word)
1919
allWords.push(word)
2020

2121
if (stem !== word) {

src/components/app/SearchBar.spec.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,27 @@ describe('SearchBar', () => {
2323
expect(getByRole('textbox', { name: 'Search' })).to.have.value('keyword')
2424
})
2525

26+
it('fires an event after half a second when the user types a query', async () => {
27+
const onChange = sinon.fake()
28+
const { getByRole } = render(
29+
<SearchBar
30+
query={''}
31+
onSearch={onChange}
32+
hideStatuses={[]}
33+
statusesWithScenarios={[]}
34+
onFilter={sinon.fake()}
35+
/>
36+
)
37+
38+
await userEvent.type(getByRole('textbox', { name: 'Search' }), 'search text')
39+
40+
expect(onChange).not.to.have.been.called
41+
42+
await new Promise((resolve) => setTimeout(resolve, 500))
43+
44+
expect(onChange).to.have.been.called
45+
})
46+
2647
it('fires an event with the query when the form is submitted', async () => {
2748
const onChange = sinon.fake()
2849
const { getByRole } = render(

src/components/app/SearchBar.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { TestStepResultStatus as Status } from '@cucumber/messages'
22
import { faFilter, faSearch } from '@fortawesome/free-solid-svg-icons'
33
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
44
import React, { FunctionComponent } from 'react'
5+
import { useDebouncedCallback } from 'use-debounce'
56

67
import statusName from '../gherkin/statusName.js'
78
import styles from './SearchBar.module.scss'
@@ -22,11 +23,12 @@ export const SearchBar: FunctionComponent<IProps> = ({
2223
onSearch,
2324
onFilter,
2425
}) => {
26+
const debouncedSearchChange = useDebouncedCallback((newValue) => {
27+
onSearch(newValue)
28+
}, 500)
2529
const searchSubmitted = (event: React.FormEvent<HTMLFormElement>) => {
2630
event.preventDefault()
27-
const formData = new window.FormData(event.currentTarget)
28-
const query = formData.get('query')
29-
onSearch((query || '').toString())
31+
debouncedSearchChange.flush()
3032
}
3133
const filterChanged = (name: Status, show: boolean) => {
3234
onFilter(show ? hideStatuses.filter((s) => s !== name) : hideStatuses.concat(name))
@@ -42,6 +44,7 @@ export const SearchBar: FunctionComponent<IProps> = ({
4244
name="query"
4345
placeholder="Search with text or @tags"
4446
defaultValue={query}
47+
onChange={(e) => debouncedSearchChange(e.target.value)}
4548
/>
4649
<small className={styles.searchHelp}>
4750
You can search with plain text or{' '}

0 commit comments

Comments
 (0)