Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(#9544): add offline freetext search indexes #9661

Open
wants to merge 13 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions shared-libs/search/src/generate-search-requests.js
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These changes in shared-libs/search are just temporary. The proper changes will need to be made against the cht-datasource code once we re-base on top of that.

This is why I have not added any additional unit tests to cover this logic (or worried too much about code structure).

Original file line number Diff line number Diff line change
Expand Up @@ -258,10 +258,10 @@ const requestBuilders = {
}
return requests;
},
contacts: (filters, extensions) => {
contacts: (filters, freetextDdocName, extensions) => {
const shouldSortByLastVisitedDate = module.exports.shouldSortByLastVisitedDate(extensions);

const freetextRequests = freetextRequest(filters, 'medic-client/contacts_by_freetext');
const freetextRequests = freetextRequest(filters, `${freetextDdocName}/contacts_by_freetext`);
const contactsByParentRequest = getContactsByParentRequest(filters);
const typeRequest = contactTypeRequest(filters, shouldSortByLastVisitedDate);
const hasTypeRequest = typeRequest?.params.keys.length;
Expand Down Expand Up @@ -313,12 +313,13 @@ const requestBuilders = {
//
// NB: options is not required: it is an optimisation shortcut
module.exports = {
generate: (type, filters, extensions) => {
generate: (type, filters, extensions, offline) => {
const freetextDdocName = offline ? 'medic-offline-freetext' : 'medic-client';
const builder = requestBuilders[type];
if (!builder) {
throw new Error('Unknown type: ' + type);
}
return builder(filters, extensions);
return builder(filters, freetextDdocName, extensions);
},
shouldSortByLastVisitedDate: (extensions) => {
return Boolean(extensions?.sortByLastVisitedDate);
Expand Down
10 changes: 8 additions & 2 deletions shared-libs/search/src/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ _.flatten = require('lodash/flatten');
_.intersection = require('lodash/intersection');
const GenerateSearchRequests = require('./generate-search-requests');

const ddocExists = (db, ddocId) => db
.get(ddocId)
.then(() => true)
.catch(() => false);

module.exports = function(Promise, DB) {
// Get the subset of rows, in appropriate order, according to options.
const getPageRows = function(type, rows, options) {
Expand Down Expand Up @@ -111,17 +116,18 @@ module.exports = function(Promise, DB) {
});
};

return function(type, filters, options, extensions) {
return async (type, filters, options, extensions) => {
options = options || {};
_.defaults(options, {
limit: 50,
skip: 0
});

const offline = await ddocExists(DB, '_design/medic-offline-freetext');
const cacheQueryResults = GenerateSearchRequests.shouldSortByLastVisitedDate(extensions);
let requests;
try {
requests = GenerateSearchRequests.generate(type, filters, extensions);
requests = GenerateSearchRequests.generate(type, filters, extensions, offline);
} catch (err) {
return Promise.reject(err);
}
Expand Down
3 changes: 2 additions & 1 deletion shared-libs/search/test/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ describe('Search service', function() {
GenerateSearchRequests.generate = sinon.stub();
GenerateSearchRequests.shouldSortByLastVisitedDate = sinon.stub();
DB = {
query: sinon.stub()
query: sinon.stub(),
get: sinon.stub().rejects()
};

service = Search(Promise, DB);
Expand Down
110 changes: 74 additions & 36 deletions tests/e2e/default/contacts/search-contacts.wdio-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,63 +5,101 @@ const contactPage = require('@page-objects/default/contacts/contacts.wdio.page')
const commonPage = require('@page-objects/default/common/common.wdio.page');
const placeFactory = require('@factories/cht/contacts/place');
const personFactory = require('@factories/cht/contacts/person');
const userFactory = require('@factories/cht/users/users');

describe('Contact Search', () => {
const places = placeFactory.generateHierarchy();
const districtHospitalId = places.get('district_hospital')._id;

const sittuHospital = placeFactory.place().build({
name: 'Sittu Hospital',
type: 'district_hospital',
parent: { _id: '', parent: { _id: '' } }
const sittuHealthCenter = placeFactory.place().build({
name: 'Sittu Health Center',
type: 'health_center',
parent: { _id: districtHospitalId, parent: { _id: '' } }
});

const potuHospital = placeFactory.place().build({
name: 'Potu Hospital',
type: 'district_hospital',
parent: { _id: '', parent: { _id: '' } }
const potuHealthCenter = placeFactory.place().build({
sugat009 marked this conversation as resolved.
Show resolved Hide resolved
name: 'Potu Health Center',
type: 'health_center',
parent: { _id: districtHospitalId, parent: { _id: '' } }
});

const sittuPerson = personFactory.build({
name: 'Sittu',
parent: { _id: sittuHospital._id, parent: sittuHospital.parent }
parent: { _id: sittuHealthCenter._id, parent: sittuHealthCenter.parent }
});

const potuPerson = personFactory.build({
name: 'Potu',
parent: { _id: sittuHospital._id, parent: sittuHospital.parent }
parent: { _id: sittuHealthCenter._id, parent: sittuHealthCenter.parent }
});

before(async () => {
await utils.saveDocs([...places.values(), sittuHospital, sittuPerson, potuHospital, potuPerson]);
await loginPage.cookieLogin();
await commonPage.goToPeople();
const supervisorPerson = personFactory.build({
name: 'Supervisor',
parent: { _id: districtHospitalId }
});

it('search by NON empty string should display results with contains match and clears search', async () => {
await contactPage.getAllLHSContactsNames();

await searchPage.performSearch('sittu');
expect(await contactPage.getAllLHSContactsNames()).to.have.members([
sittuPerson.name,
sittuHospital.name,
]);
const offlineUser = userFactory.build({
username: 'offline-search-user',
place: districtHospitalId,
roles: ['chw_supervisor'],
contact: supervisorPerson._id
});
const onlineUser = userFactory.build({
username: 'online-search-user',
place: districtHospitalId,
roles: ['program_officer'],
contact: supervisorPerson._id
});

await searchPage.clearSearch();
expect(await contactPage.getAllLHSContactsNames()).to.have.members([
potuHospital.name,
sittuHospital.name,
places.get('district_hospital').name,
before(async () => {
await utils.saveDocs([
...places.values(), sittuHealthCenter, sittuPerson, potuHealthCenter, potuPerson, supervisorPerson
]);
await utils.createUsers([offlineUser, onlineUser]);
});

it('search should clear RHS selected contact', async () => {
await contactPage.selectLHSRowByText(potuHospital.name, false);
await contactPage.waitForContactLoaded();
expect(await (await contactPage.contactCardSelectors.contactCardName()).getText()).to.equal(potuHospital.name);
after(() => utils.deleteUsers([offlineUser, onlineUser]));

await searchPage.performSearch('sittu');
await contactPage.waitForContactUnloaded();
const url = await browser.getUrl();
expect(url.endsWith('/contacts')).to.equal(true);
});
[
['online', onlineUser],
['offline', offlineUser],
].forEach(([userType, user]) => describe(`Logged in as an ${userType} user`, () => {
before(async () => {
await loginPage.login(user);
await commonPage.goToPeople();
});

after(commonPage.logout);

it('search by NON empty string should display results with contains match and clears search', async () => {
jkuester marked this conversation as resolved.
Show resolved Hide resolved
await contactPage.getAllLHSContactsNames();

await searchPage.performSearch('sittu');
expect(await contactPage.getAllLHSContactsNames()).to.have.members([
sittuPerson.name,
sittuHealthCenter.name,
]);

await searchPage.clearSearch();
expect(await contactPage.getAllLHSContactsNames()).to.have.members([
potuHealthCenter.name,
sittuHealthCenter.name,
places.get('district_hospital').name,
places.get('health_center').name,
]);
});

it('search should clear RHS selected contact', async () => {
await contactPage.selectLHSRowByText(potuHealthCenter.name, false);
await contactPage.waitForContactLoaded();
expect(
await (await contactPage.contactCardSelectors.contactCardName()).getText()
).to.equal(potuHealthCenter.name);

await searchPage.performSearch('sittu');
await contactPage.waitForContactUnloaded();
const url = await browser.getUrl();
expect(url.endsWith('/contacts')).to.equal(true);
});
}));
});
6 changes: 4 additions & 2 deletions tests/e2e/default/db/initial-replication.wdio-spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ const commonPage = require('@page-objects/default/common/common.wdio.page');
const loginPage = require('@page-objects/default/login/login.wdio.page');
const dataFactory = require('@factories/cht/generate');

const LOCAL_ONLY_DOC_IDS = ['_design/medic-offline-freetext'];

describe('initial-replication', () => {
const LOCAL_LOG = '_local/initial-replication';

Expand Down Expand Up @@ -52,11 +54,11 @@ describe('initial-replication', () => {

await commonPage.sync(false, 7000);

const localAllDocs = await chtDbUtils.getDocs();
const localAllDocs = (await chtDbUtils.getDocs()).filter(doc => !LOCAL_ONLY_DOC_IDS.includes(doc.id));
const localDocIds = dataFactory.ids(localAllDocs);

// no additional docs to download
expect(docIdsPreSync).to.have.members(localDocIds);
expect(docIdsPreSync).to.have.members([...localDocIds, ...LOCAL_ONLY_DOC_IDS]);

const serverAllDocs = await getServerDocs(localDocIds);

Expand Down
97 changes: 45 additions & 52 deletions webapp/src/js/bootstrapper/index.js
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This bootstrapper code gets run during the main initialization of the app (before any of the Angular code spins up. This bootstrapping gets run during initial login and also when reloading the app (e.g. when the webapp-code/app-settings/ddocs change).

So, any time in the future when we make updates/additions/removals of views here, we can be confident that everything will be initialized properly in the local db when the app reloads.

jkuester marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
const utils = require('./utils');
const purger = require('./purger');
const initialReplicationLib = require('./initial-replication');
const offlineDdocs = require('./offline-ddocs');

const ONLINE_ROLE = 'mm-online';

Expand Down Expand Up @@ -84,7 +85,7 @@
};

/* pouch db set up function */
module.exports = (POUCHDB_OPTIONS) => {
module.exports = async (POUCHDB_OPTIONS) => {

const dbInfo = getDbInfo();
const userCtx = getUserCtx();
Expand All @@ -108,58 +109,50 @@

const localMetaDb = window.PouchDB(getLocalMetaDbName(dbInfo, userCtx.name), POUCHDB_OPTIONS.local);

return Promise
.all([
initialReplicationLib.isReplicationNeeded(localDb, userCtx),
swRegistration,
setReplicationId(POUCHDB_OPTIONS, localDb)
])
.then(([isInitialReplicationNeeded]) => {
utils.setOptions(POUCHDB_OPTIONS);

if (isInitialReplicationNeeded) {
const replicationStarted = performance.now();
// Polling the document count from the db.
return initialReplicationLib
.replicate(remoteDb, localDb)
.then(() => initialReplicationLib.isReplicationNeeded(localDb, userCtx))
.then(isReplicationStillNeeded => {
if (isReplicationStillNeeded) {
throw new Error('Initial replication failed');
}
})
.then(() => window.startupTimes.replication = performance.now() - replicationStarted);
}
})
.then(() => {
const purgeMetaStarted = performance.now();
return purger
.purgeMeta(localMetaDb)
.on('should-purge', shouldPurge => window.startupTimes.purgingMeta = shouldPurge)
.on('start', () => setUiStatus('PURGE_META'))
.on('done', () => window.startupTimes.purgeMeta = performance.now() - purgeMetaStarted)
.catch(err => {
console.error('Error attempting to purge meta db - continuing', err);
window.startupTimes.purgingMetaFailed = err.message;
});
})
.then(() => setUiStatus('STARTING_APP'))
.catch(err => err)
.then(err => {
localDb.close();
remoteDb.close();
localMetaDb.close();

if (err) {
const errorCode = err.status || err.code;
if (errorCode === 401) {
return redirectToLogin(dbInfo);
}
setUiError(err);
throw (err);
try {
const [isInitialReplicationNeeded] = await Promise
.all([
initialReplicationLib.isReplicationNeeded(localDb, userCtx),
swRegistration,
setReplicationId(POUCHDB_OPTIONS, localDb),
offlineDdocs.init(localDb)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the only real change here. The rest is just a quick re-factor to use async/await.

]);

utils.setOptions(POUCHDB_OPTIONS);

if (isInitialReplicationNeeded) {
const replicationStarted = performance.now();
// Polling the document count from the db.
await initialReplicationLib.replicate(remoteDb, localDb);
if (await initialReplicationLib.isReplicationNeeded(localDb, userCtx)) {
throw new Error('Initial replication failed');
}
});
window.startupTimes.replication = performance.now() - replicationStarted;
}

const purgeMetaStarted = performance.now();
await purger
.purgeMeta(localMetaDb)
.on('should-purge', shouldPurge => window.startupTimes.purgingMeta = shouldPurge)
.on('start', () => setUiStatus('PURGE_META'))
.on('done', () => window.startupTimes.purgeMeta = performance.now() - purgeMetaStarted)
.catch(err => {
console.error('Error attempting to purge meta db - continuing', err);
window.startupTimes.purgingMetaFailed = err.message;
});

setUiStatus('STARTING_APP');
} catch (err) {
const errorCode = err.status || err.code;
if (errorCode === 401) {
return redirectToLogin(dbInfo);
}
setUiError(err);
throw (err);
} finally {
localDb.close();
remoteDb.close();
localMetaDb.close();
}
};

}());
5 changes: 5 additions & 0 deletions webapp/src/js/bootstrapper/offline-ddocs/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"globals": {
"emit": true
}
}
18 changes: 18 additions & 0 deletions webapp/src/js/bootstrapper/offline-ddocs/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
const contactsByFreetext = require('./medic-offline-freetext');

const getRev = async (db, id) => db
.get(id)
.then(({ _rev }) => _rev)
.catch((e) => {
if (e.status === 404) {
return undefined;
}
throw e;
});

const initDdoc = async (db, ddoc) => db.put({
...ddoc,
_rev: await getRev(db, ddoc._id),
});

module.exports.init = async (db) => initDdoc(db, contactsByFreetext);
Loading
Loading