diff options
author | Wouter Admiraal <wouter.admiraal@sonarsource.com> | 2022-01-04 11:32:45 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2022-01-05 20:02:50 +0000 |
commit | 51dcdb9aab1d81e42c1d2d0bfbe6e4f2f2b7c8ca (patch) | |
tree | 32b9377769d7ff0f02e77c2ec74cad8710b20083 /server | |
parent | cabc188db553bd5201a1c1b695a8677e0cea6299 (diff) | |
download | sonarqube-51dcdb9aab1d81e42c1d2d0bfbe6e4f2f2b7c8ca.tar.gz sonarqube-51dcdb9aab1d81e42c1d2d0bfbe6e4f2f2b7c8ca.zip |
SONAR-15819 Fix documentation search
Diffstat (limited to 'server')
5 files changed, 241 insertions, 67 deletions
diff --git a/server/sonar-web/src/main/js/apps/documentation/components/SearchResults.tsx b/server/sonar-web/src/main/js/apps/documentation/components/SearchResults.tsx index 9640472b73c..906ddfba954 100644 --- a/server/sonar-web/src/main/js/apps/documentation/components/SearchResults.tsx +++ b/server/sonar-web/src/main/js/apps/documentation/components/SearchResults.tsx @@ -29,7 +29,7 @@ import SearchResultEntry from './SearchResultEntry'; interface Props { navigation: DocNavigationItem[]; pages: DocumentationEntry[]; - query: string; + query?: string; splat: string; } @@ -46,15 +46,13 @@ export default class SearchResults extends React.PureComponent<Props> { this.metadataWhitelist = ['position', 'tokenContext']; - props.pages - .filter(page => getUrlsList(props.navigation).includes(page.url)) - .forEach(page => this.add(page)); + const urlsList = getUrlsList(props.navigation); + props.pages.filter(page => urlsList.includes(page.url)).forEach(page => this.add(page)); }); } - render() { - const query = this.props.query.toLowerCase(); - const results = this.index + search(query: string) { + return this.index .search( query .replace(/[\^\-+:~*]/g, '') @@ -63,10 +61,11 @@ export default class SearchResults extends React.PureComponent<Props> { .join(' ') ) .map(match => { - const page = this.props.pages.find(page => page.relativeName === match.ref); + const page = this.props.pages.find(p => p.relativeName === match.ref); + // This should never happen, but provide this check for type safety. if (!page) { - return null; + return undefined; } const highlights: T.Dict<[number, number][]> = {}; @@ -101,6 +100,16 @@ export default class SearchResults extends React.PureComponent<Props> { return { exactMatch, highlights, longestTerm, page, query }; }) .filter(isDefined); + } + + render() { + const query = this.props.query?.toLowerCase(); + + if (!query) { + return null; + } + + const results = this.search(query); // Re-order results by the length of the longest matched term and by exact // match (if applicable). The longer the matched term is, the higher the @@ -138,18 +147,18 @@ export default class SearchResults extends React.PureComponent<Props> { // searched for, the better the standard algorithm will perform anyway. In the // end, the best would be for Lunr to support multi-term matching, as extending // the search algorithm for this would be way too complicated. -function tokenContextPlugin(builder: LunrBuilder) { - const pipelineFunction = (token: LunrToken, index: number, tokens: LunrToken[]) => { - const prevToken = tokens[index - 1] || ''; - const nextToken = tokens[index + 1] || ''; - token.metadata['tokenContext'] = [prevToken.toString(), token.toString(), nextToken.toString()] - .filter(s => s.length) - .join(' ') - .toLowerCase(); - return token; - }; - - (lunr as any).Pipeline.registerFunction(pipelineFunction, 'tokenContext'); - builder.pipeline.before((lunr as any).stemmer, pipelineFunction); +export function tokenContextPluginCallback(token: LunrToken, index: number, tokens: LunrToken[]) { + const prevToken = tokens[index - 1] || ''; + const nextToken = tokens[index + 1] || ''; + token.metadata['tokenContext'] = [prevToken.toString(), token.toString(), nextToken.toString()] + .filter(s => s.length) + .join(' ') + .toLowerCase(); + return token; +} + +export function tokenContextPlugin(builder: LunrBuilder) { + (lunr as any).Pipeline.registerFunction(tokenContextPluginCallback, 'tokenContext'); + builder.pipeline.before((lunr as any).stemmer, tokenContextPluginCallback); builder.metadataWhitelist.push('tokenContext'); } diff --git a/server/sonar-web/src/main/js/apps/documentation/components/Sidebar.tsx b/server/sonar-web/src/main/js/apps/documentation/components/Sidebar.tsx index 69cae6bc2f8..cf2dbe03cfc 100644 --- a/server/sonar-web/src/main/js/apps/documentation/components/Sidebar.tsx +++ b/server/sonar-web/src/main/js/apps/documentation/components/Sidebar.tsx @@ -53,14 +53,14 @@ export default class Sidebar extends React.PureComponent<Props, State> { /> <div className="documentation-results panel"> <div className="list-group"> - {this.state.query ? ( - <SearchResults - navigation={this.props.navigation} - pages={this.props.pages} - query={this.state.query} - splat={this.props.splat} - /> - ) : ( + <SearchResults + navigation={this.props.navigation} + pages={this.props.pages} + query={this.state.query} + splat={this.props.splat} + /> + + {!this.state.query && ( <Menu navigation={this.props.navigation} pages={this.props.pages} diff --git a/server/sonar-web/src/main/js/apps/documentation/components/__tests__/SearchResults-test.tsx b/server/sonar-web/src/main/js/apps/documentation/components/__tests__/SearchResults-test.tsx index cb15ab5066d..890c7e4ab6d 100644 --- a/server/sonar-web/src/main/js/apps/documentation/components/__tests__/SearchResults-test.tsx +++ b/server/sonar-web/src/main/js/apps/documentation/components/__tests__/SearchResults-test.tsx @@ -18,9 +18,17 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import { shallow } from 'enzyme'; -import lunr from 'lunr'; +import lunr, { LunrToken } from 'lunr'; import * as React from 'react'; -import SearchResults from '../SearchResults'; +import { mockDocumentationEntry } from '../../../../helpers/testMocks'; +import { getUrlsList } from '../../navTreeUtils'; +import { DocumentationEntry } from '../../utils'; +import SearchResultEntry from '../SearchResultEntry'; +import SearchResults, { tokenContextPlugin, tokenContextPluginCallback } from '../SearchResults'; + +jest.mock('../../navTreeUtils', () => ({ + getUrlsList: jest.fn().mockReturnValue([]) +})); jest.mock('lunr', () => jest.fn(() => ({ @@ -53,7 +61,7 @@ jest.mock('lunr', () => [111, 6], [118, 4] ], - tokenContext: ['keywords simply text'] + tokenContext: ['dummy simply text'] } } } @@ -63,40 +71,162 @@ jest.mock('lunr', () => })) ); -function createPage(title: string, relativeName: string, text = '') { - return { relativeName, url: '/' + relativeName, title, navTitle: undefined, text, content: text }; -} +it('should render correctly', () => { + expect(shallowRender()).toMatchSnapshot('default'); + expect(shallowRender({ query: '' }).type()).toBeNull(); +}); + +describe('search engine', () => { + class LunrIndexMock { + plugins: Function[] = []; + fields: Array<{ field: string; args: any }> = []; + docs: DocumentationEntry[] = []; + metadataWhitelist: string[] = []; + + use(fn: Function) { + this.plugins.push(fn); + } + + ref(_ref: string) { + /* noop */ + } + + field(field: string, args = {}) { + this.fields.push({ field, args }); + } + + add(doc: DocumentationEntry) { + this.docs.push(doc); + } + } + + it('should correctly populate the index', () => { + (getUrlsList as jest.Mock).mockReturnValueOnce(['/lorem/index', '/lorem/origin']); + + shallowRender(); + + // Fetch the callback passed to lunr(), which serves as the index constructor. + const indexConstructor: Function = (lunr as jest.Mock).mock.calls[0][0]; + + // Apply it to our mock index. + const lunrMock = new LunrIndexMock(); + indexConstructor.apply(lunrMock); + + expect(lunrMock.docs.length).toBe(2); + expect(lunrMock.plugins).toContain(tokenContextPlugin); + expect(lunrMock.metadataWhitelist).toEqual(['position', 'tokenContext']); + expect(lunrMock.fields).toEqual([ + expect.objectContaining({ field: 'title', args: { boost: 10 } }), + expect.objectContaining({ field: 'text' }) + ]); + }); + + it('should correctly look for an exact match', () => { + // No exact match, should sort as the matches came in. + const wrapper = shallowRender({ query: 'text simply' }); + expect( + wrapper + .find(SearchResultEntry) + .at(0) + .props().result.page.relativeName + ).toBe('lorem/origin'); -const pages = [ - createPage( - 'Lorem Ipsum', - 'lorem/index', - "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book." - ), - createPage( - 'Where does it come from?', - 'lorem/origin', - 'Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words.' - ), - createPage( - 'Where does Foobar come from?', - 'foobar', - 'Foobar is a universal variable understood to represent whatever is being discussed. Now we need some keywords: simply text.' - ) -]; - -it('should search', () => { - const wrapper = shallow( + // Exact match, specific page should be at the top. + wrapper.setProps({ query: 'simply text' }); + expect( + wrapper + .find(SearchResultEntry) + .at(0) + .props().result.page.relativeName + ).toBe('foobar'); + }); + + it('should trigger a search if query is set', () => { + const wrapper = shallowRender({ query: undefined }); + expect(wrapper.instance().index.search).not.toBeCalled(); + wrapper.setProps({ query: 'si:+mply text' }); + expect(wrapper.instance().index.search).toBeCalledWith('simply~1 simply* text~1 text*'); + }); +}); + +describe('tokenContextPluginCallback', () => { + class LunrTokenMock { + str: string; + metadata: any; + + constructor(str: string) { + this.str = str; + this.metadata = {}; + } + + toString() { + return this.str; + } + } + + function mockLunrToken(str: string): LunrToken { + return new LunrTokenMock(str); + } + + it('should correctly provide token context for text', () => { + const tokens = [ + mockLunrToken('this'), + mockLunrToken('is'), + mockLunrToken('some'), + mockLunrToken('text') + ]; + + expect(tokenContextPluginCallback(mockLunrToken('this'), 0, tokens).metadata).toEqual( + expect.objectContaining({ tokenContext: 'this is' }) + ); + expect(tokenContextPluginCallback(mockLunrToken('is'), 1, tokens).metadata).toEqual( + expect.objectContaining({ tokenContext: 'this is some' }) + ); + expect(tokenContextPluginCallback(mockLunrToken('some'), 2, tokens).metadata).toEqual( + expect.objectContaining({ tokenContext: 'is some text' }) + ); + expect(tokenContextPluginCallback(mockLunrToken('text'), 3, tokens).metadata).toEqual( + expect.objectContaining({ tokenContext: 'some text' }) + ); + }); +}); + +function shallowRender(props: Partial<SearchResults['props']> = {}) { + return shallow<SearchResults>( <SearchResults navigation={['lorem/index', 'lorem/origin', 'foobar']} - pages={pages} - query="si:+mply text" + pages={[ + mockDocumentationEntry({ + title: 'Lorem Ipsum', + relativeName: 'lorem/index', + url: '/lorem/index', + content: + "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.", + text: + "Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book." + }), + mockDocumentationEntry({ + title: 'Where does it come from?', + relativeName: 'lorem/origin', + url: '/lorem/origin', + content: + 'Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words.', + text: + 'Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words.' + }), + mockDocumentationEntry({ + title: 'Where does Foobar come from?', + relativeName: 'foobar', + url: '/foobar', + content: + 'Foobar is a universal variable understood to represent whatever is being discussed. Now we need some keywords: simply text.', + text: + 'Foobar is a universal variable understood to represent whatever is being discussed. Now we need some keywords: simply text.' + }) + ]} + query="what is 42" splat="foobar" + {...props} /> ); - expect(wrapper).toMatchSnapshot(); - expect(lunr).toBeCalled(); - expect((wrapper.instance() as SearchResults).index.search).toBeCalledWith( - 'simply~1 simply* text~1 text*' - ); -}); +} diff --git a/server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/SearchResults-test.tsx.snap b/server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/SearchResults-test.tsx.snap index 65b92fef234..ae487ab0528 100644 --- a/server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/SearchResults-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/SearchResults-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should search 1`] = ` +exports[`should render correctly: default 1`] = ` <Fragment> <SearchResultEntry active={false} @@ -35,7 +35,7 @@ exports[`should search 1`] = ` "title": "Where does it come from?", "url": "/lorem/origin", }, - "query": "si:+mply text", + "query": "what is 42", } } /> @@ -72,7 +72,7 @@ exports[`should search 1`] = ` "title": "Where does Foobar come from?", "url": "/foobar", }, - "query": "si:+mply text", + "query": "what is 42", } } /> diff --git a/server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/Sidebar-test.tsx.snap b/server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/Sidebar-test.tsx.snap index 0b051b98546..b83396b9873 100644 --- a/server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/Sidebar-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/Sidebar-test.tsx.snap @@ -15,6 +15,41 @@ exports[`should render menu 1`] = ` <div className="list-group" > + <SearchResults + navigation={ + Array [ + Object { + "children": Array [ + "/lorem/index", + ], + "title": "Block", + }, + "foobar", + ] + } + pages={ + Array [ + Object { + "content": "", + "navTitle": undefined, + "relativeName": "lorem/index", + "text": "", + "title": "Lorem Ipsum", + "url": "/lorem/index", + }, + Object { + "content": "", + "navTitle": undefined, + "relativeName": "foobar", + "text": "", + "title": "Where does Foobar come from?", + "url": "/foobar", + }, + ] + } + query="" + splat="foobar" + /> <Menu navigation={ Array [ |