Refactored search-index module to use class syntax

refs https://github.com/TryGhost/Team/issues/1665

- The class syntax allows to use constructor DI pattern that helps with unit testing and abstracting away the dependencies
- It wasn't possible to test the internals without having access to the localStorage in tests (you couls access it but that's kind of leaky for tests to know what mechanism is used in the module intenally)
This commit is contained in:
Naz 2022-07-05 11:22:36 +02:00
parent ce4206e91f
commit d9e9d451c9
3 changed files with 91 additions and 52 deletions

View File

@ -2,23 +2,24 @@ import React from 'react';
import './App.css';
import AppContext from './AppContext';
import PopupModal from './components/PopupModal';
import {init as initSearchIndex} from './search-index.js';
import SearchIndex from './search-index.js';
export default class App extends React.Component {
constructor(props) {
super(props);
this.state = {
const searchIndex = new SearchIndex({
apiUrl: props.apiUrl,
apiKey: props.apiKey
});
this.state = {
searchIndex
};
}
componentDidMount() {
initSearchIndex({
apiUrl: this.state.apiUrl,
apiKey: this.state.apiKey
});
this.state.searchIndex.init();
}
render() {

View File

@ -1,57 +1,68 @@
import elasticlunr from 'elasticlunr';
let index;
export default class SearchIndex {
constructor({apiUrl, apiKey, storage = localStorage}) {
this.apiUrl = apiUrl;
this.apiKey = apiKey;
this.storage = storage;
export const init = async function ({apiUrl, apiKey}) {
// remove default stop words to search of *any* word
elasticlunr.clearStopWords();
this.index = null;
const url = `${apiUrl}/posts/?key=${apiKey}&limit=all&fields=id,title,excerpt,url,updated_at,visibility&order=updated_at%20desc&formats=plaintext`;
this.init = this.init.bind(this);
this.search = this.search.bind(this);
}
const indexDump = JSON.parse(localStorage.getItem('ease_search_index'));
localStorage.removeItem('ease_index');
localStorage.removeItem('ease_last');
function update(data) {
data.posts.forEach(function (post) {
index.addDoc(post);
#updateIndex(data) {
data.posts.forEach((post) => {
this.index.addDoc(post);
});
localStorage.setItem('ease_search_index', JSON.stringify(index));
localStorage.setItem('ease_search_last', data.posts[0].updated_at);
this.storage.setItem('ease_search_index', JSON.stringify(this.index));
this.storage.setItem('ease_search_last', data.posts[0].updated_at);
}
if (
!indexDump
) {
return fetch(url)
.then(response => response.json())
.then((data) => {
if (data.posts.length > 0) {
index = elasticlunr(function () {
this.addField('title');
this.addField('plaintext');
this.setRef('id');
});
async init() {
// remove default stop words to search of *any* word
elasticlunr.clearStopWords();
update(data);
}
});
} else {
index = elasticlunr.Index.load(indexDump);
const url = `${this.apiUrl}/posts/?key=${this.apiKey}&limit=all&fields=id,title,excerpt,url,updated_at,visibility&order=updated_at%20desc&formats=plaintext`;
return fetch(`${url}&filter=updated_at:>'${localStorage.getItem('ease_search_last').replace(/\..*/, '').replace(/T/, ' ')}'`
)
.then(response => response.json())
.then((data) => {
if (data.posts.length > 0) {
update(data);
}
});
const indexDump = JSON.parse(this.storage.getItem('ease_search_index'));
this.storage.removeItem('ease_index');
this.storage.removeItem('ease_last');
if (
!indexDump
) {
return fetch(url)
.then(response => response.json())
.then((data) => {
if (data.posts.length > 0) {
this.index = elasticlunr(function () {
this.addField('title');
this.addField('plaintext');
this.setRef('id');
});
this.#updateIndex(data);
}
});
} else {
this.index = elasticlunr.Index.load(indexDump);
return fetch(`${url}&filter=updated_at:>'${this.storage.getItem('ease_search_last').replace(/\..*/, '').replace(/T/, ' ')}'`
)
.then(response => response.json())
.then((data) => {
if (data.posts.length > 0) {
this.#updateIndex(data);
}
});
}
}
};
export const search = function (value) {
return index.search(value, {expand: true});
};
search(value) {
return this.index.search(value, {expand: true});
}
}

View File

@ -1,10 +1,15 @@
import {init} from './search-index';
import SearchIndex from './search-index';
import nock from 'nock';
describe('search index', function () {
afterEach(function () {
localStorage.clear();
});
test('initializes search index', async () => {
const apiUrl = 'http://localhost/ghost/api/content';
const apiKey = 'secret_key';
const searchIndex = new SearchIndex({apiUrl, apiKey, storage: localStorage});
const scope = nock('http://localhost/ghost/api/content')
.get('/posts/?key=secret_key&limit=all&fields=id,title,excerpt,url,updated_at,visibility&order=updated_at%20desc&formats=plaintext')
@ -12,8 +17,30 @@ describe('search index', function () {
posts: [{}]
});
await init({apiUrl, apiKey});
await searchIndex.init({apiUrl, apiKey});
expect(scope.isDone()).toBeTruthy();
});
test('allows to search for indexed posts', async () => {
const apiUrl = 'http://localhost/ghost/api/content';
const apiKey = 'secret_key';
const searchIndex = new SearchIndex({apiUrl, apiKey, storage: localStorage});
nock('http://localhost/ghost/api/content')
.get('/posts/?key=secret_key&limit=all&fields=id,title,excerpt,url,updated_at,visibility&order=updated_at%20desc&formats=plaintext')
.reply(200, {
posts: [{
id: 'sounique',
title: 'Amazing Barcelona Life',
plaintext: 'We are sitting by the pool and smashing out search features'
}]
});
await searchIndex.init({apiUrl, apiKey});
const searchResults = searchIndex.search('Barcelona');
expect(searchResults.length).toEqual(1);
});
});