aboutsummaryrefslogtreecommitdiffstats
path: root/server
diff options
context:
space:
mode:
authorWouter Admiraal <wouter.admiraal@sonarsource.com>2022-01-04 11:32:45 +0100
committersonartech <sonartech@sonarsource.com>2022-01-05 20:02:50 +0000
commit51dcdb9aab1d81e42c1d2d0bfbe6e4f2f2b7c8ca (patch)
tree32b9377769d7ff0f02e77c2ec74cad8710b20083 /server
parentcabc188db553bd5201a1c1b695a8677e0cea6299 (diff)
downloadsonarqube-51dcdb9aab1d81e42c1d2d0bfbe6e4f2f2b7c8ca.tar.gz
sonarqube-51dcdb9aab1d81e42c1d2d0bfbe6e4f2f2b7c8ca.zip
SONAR-15819 Fix documentation search
Diffstat (limited to 'server')
-rw-r--r--server/sonar-web/src/main/js/apps/documentation/components/SearchResults.tsx53
-rw-r--r--server/sonar-web/src/main/js/apps/documentation/components/Sidebar.tsx16
-rw-r--r--server/sonar-web/src/main/js/apps/documentation/components/__tests__/SearchResults-test.tsx198
-rw-r--r--server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/SearchResults-test.tsx.snap6
-rw-r--r--server/sonar-web/src/main/js/apps/documentation/components/__tests__/__snapshots__/Sidebar-test.tsx.snap35
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 [