Skip to content

Commit

Permalink
feat(#8074): add filter by parent to db-object widget (#8759)
Browse files Browse the repository at this point in the history
This feature is applied when using the appearance:descendant-of-current-contact
The scope remains for forms that are opened in the contact tab; it takes the contact ID from the URL.
It uses the CouchDB view contact_by_parent in combination with the contact type.
  • Loading branch information
latin-panda authored Jan 10, 2024
1 parent 7ebd7bf commit 4547885
Show file tree
Hide file tree
Showing 13 changed files with 383 additions and 63 deletions.
97 changes: 58 additions & 39 deletions shared-libs/search/src/generate-search-requests.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,20 @@ const subjectRequest = function(filters) {
return getRequestWithMappedKeys('medic-client/reports_by_subject', subjectIds);
};

const getContactsByParentRequest = function(filters) {
if (!filters.parent) {
return;
}

const types = filters?.types?.selected;
return {
view: 'medic-client/contacts_by_parent',
params: {
keys: types ? types.map(type => ([ filters.parent, type ])) : [ filters.parent ],
},
};
};

const contactTypeRequest = function(filters, sortByLastVisitedDate) {
if (!filters.types) {
return;
Expand Down Expand Up @@ -175,6 +189,34 @@ const sortByLastVisitedDate = function() {
};
};

const makeCombinedParams = function(freetextRequest, typeKey) {
const type = typeKey[0];
const params = {};
if (freetextRequest.key) {
params.key = [ type, freetextRequest.params.key[0] ];
} else {
params.startkey = [ type, freetextRequest.params.startkey[0] ];
params.endkey = [ type, freetextRequest.params.endkey[0] ];
}
return params;
};

const getContactsByTypeAndFreetextRequest = function(typeRequests, freetextRequest) {
const result = {
view: 'medic-client/contacts_by_type_freetext',
union: typeRequests.params.keys.length > 1
};

if (result.union) {
result.paramSets =
typeRequests.params.keys.map(_.partial(makeCombinedParams, freetextRequest, _));
return result;
}

result.params = makeCombinedParams(freetextRequest, typeRequests.params.keys[0]);
return result;
};

const requestBuilders = {
reports: function(filters) {
let requests = [
Expand All @@ -196,57 +238,34 @@ const requestBuilders = {
contacts: function(filters, extensions) {
const shouldSortByLastVisitedDate = module.exports.shouldSortByLastVisitedDate(extensions);

const typeRequest = contactTypeRequest(filters, shouldSortByLastVisitedDate);
const hasTypeRequest = typeRequest && typeRequest.params.keys.length;

const freetextRequests = freetextRequest(filters, 'medic-client/contacts_by_freetext');
const hasFreetextRequests = freetextRequests && freetextRequests.length;

if (hasTypeRequest && hasFreetextRequests) {

const makeCombinedParams = function(freetextRequest, typeKey) {
const type = typeKey[0];
const params = {};
if (freetextRequest.key) {
params.key = [ type, freetextRequest.params.key[0] ];
} else {
params.startkey = [ type, freetextRequest.params.startkey[0] ];
params.endkey = [ type, freetextRequest.params.endkey[0] ];
}
return params;
};

const makeCombinedRequest = function(typeRequests, freetextRequest) {
const result = {
view: 'medic-client/contacts_by_type_freetext',
union: typeRequests.params.keys.length > 1
};

if (result.union) {
result.paramSets =
typeRequests.params.keys.map(_.partial(makeCombinedParams, freetextRequest, _));
return result;
}

result.params = makeCombinedParams(freetextRequest, typeRequests.params.keys[0]);
return result;
};
const contactsByParentRequest = getContactsByParentRequest(filters);
const typeRequest = contactTypeRequest(filters, shouldSortByLastVisitedDate);
const hasTypeRequest = typeRequest?.params.keys.length;

return freetextRequests.map(_.partial(makeCombinedRequest, typeRequest, _));
if (contactsByParentRequest && hasTypeRequest && !freetextRequests?.length) {
// The request's keys already have the type included.
return [ contactsByParentRequest ];
}

let requests = [ freetextRequests, typeRequest ];
requests = _.compact(_.flatten(requests));
if (hasTypeRequest && freetextRequests?.length) {
const combinedRequests = freetextRequests.map(_.partial(getContactsByTypeAndFreetextRequest, typeRequest, _));
if (contactsByParentRequest) {
combinedRequests.unshift(contactsByParentRequest);
}
return combinedRequests;
}

const requests = _.compact(_.flatten([ freetextRequests, typeRequest, contactsByParentRequest ]));
if (!requests.length) {
requests.push(defaultContactRequest());
}

if (shouldSortByLastVisitedDate) {
// Always push this last, search:getIntersection uses the last request
// result and we'll need it later for sorting
// Always push this last, search:getIntersection uses the last request's result, we'll need it later for sorting.
requests.push(sortByLastVisitedDate());
}

return requests;
}
};
Expand Down
57 changes: 57 additions & 0 deletions shared-libs/search/test/generate-search-requests.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,63 @@ describe('GenerateSearchRequests service', function() {
});
});

it('creates request to filter contacts by parent when contact ID and types are provided', function() {
const filters = {
types: {
selected: [ 'person' ],
},
parent: 'S-123',
};

const result = service('contacts', filters);

chai.expect(result.length).to.equal(1);
chai.expect(result[0]).to.deep.equal({
view: 'medic-client/contacts_by_parent',
params: {
keys: [ [ 'S-123', 'person' ] ],
},
});
});

it('creates request to filter contacts by parent and freetext', function() {
const filters = {
types: { selected: [ 'person' ] },
search: 'someth',
parent: 'S-123',
};

const result = service('contacts', filters);

chai.expect(result.length).to.equal(2);
chai.expect(result[0]).to.deep.equal({
view: 'medic-client/contacts_by_parent',
params: {
keys: [ [ 'S-123', 'person' ] ],
},
});
chai.expect(result[1]).to.deep.equal({
view: 'medic-client/contacts_by_type_freetext',
union: false,
params: {
endkey: [ 'person', 'someth\ufff0' ],
startkey: [ 'person', 'someth' ],
},
});
});

it('creates request to filter contacts by parent when types are not provided', function() {
const filters = { parent: 'S-123' };

const result = service('contacts', filters);

chai.expect(result.length).to.equal(1);
chai.expect(result[0]).to.deep.equal({
view: 'medic-client/contacts_by_parent',
params: { keys: [ 'S-123' ] },
});
});

it('creates unfiltered contacts request for types filter when all options are selected', function() {
const filters = {
types: {
Expand Down
82 changes: 82 additions & 0 deletions tests/e2e/default/enketo/db-object-widget.wdio-spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
const fs = require('fs');

const utils = require('@utils');
const userFactory = require('@factories/cht/users/users');
const placeFactory = require('@factories/cht/contacts/place');
const personFactory = require('@factories/cht/contacts/person');
const commonPage = require('@page-objects/default/common/common.wdio.page');
const loginPage = require('@page-objects/default/login/login.wdio.page');
const genericForm = require('@page-objects/default/enketo/generic-form.wdio.page');
const reportsPage = require('@page-objects/default/reports/reports.wdio.page');

describe('DB Object Widget', () => {
const formId = 'db-object-widget';
const form = fs.readFileSync(`${__dirname}/forms/${formId}.xml`, 'utf8');
const formDocument = {
_id: `form:${formId}`,
internalId: formId,
title: `Form ${formId}`,
type: 'form',
context: { person: true, place: true },
_attachments: {
xml: {
content_type: 'application/octet-stream',
data: Buffer.from(form).toString('base64')
}
}
};

const places = placeFactory.generateHierarchy();
const districtHospital = places.get('district_hospital');
const area1 = places.get('health_center');
const area2 = placeFactory.place().build({
_id: 'area2',
name: 'area 2',
type: 'health_center',
parent: { _id: districtHospital._id }
});

const offlineUser = userFactory.build({ place: districtHospital._id, roles: [ 'chw' ] });
const personArea1 = personFactory.build({ parent: { _id: area1._id, parent: area1.parent } });
const personArea2 = personFactory.build({ name: 'Patricio', parent: { _id: area2._id, parent: area2.parent } });

before(async () => {
await utils.saveDocs([ ...places.values(), area2, personArea1, personArea2, formDocument ]);
await utils.createUsers([ offlineUser ]);
await loginPage.login(offlineUser);
});

it('should display only the contacts from the parent contact', async () => {
await commonPage.goToPeople(area1._id);
await commonPage.openFastActionReport(formId);

const sameParent = await genericForm.getDBObjectWidgetValues('/db_object_form/people/person_test_same_parent');
await sameParent[0].click();
expect(sameParent.length).to.equal(1);
expect(sameParent[0].name).to.equal(personArea1.name);

const allContacts = await genericForm.getDBObjectWidgetValues('/db_object_form/people/person_test_all');
await allContacts[2].click();
expect(allContacts.length).to.equal(3);
expect(allContacts[0].name).to.equal(personArea1.name);
expect(allContacts[1].name).to.equal(offlineUser.contact.name);
expect(allContacts[2].name).to.equal(personArea2.name);

await genericForm.submitForm();
await commonPage.waitForPageLoaded();
await commonPage.goToReports();

const firstReport = await reportsPage.getListReportInfo(await reportsPage.firstReport());
expect(firstReport.heading).to.equal(offlineUser.contact.name);
expect(firstReport.form).to.equal('Form db-object-widget');

await reportsPage.openReport(firstReport.dataId);
expect(await reportsPage.getReportDetailFieldValueByLabel(
'report.db-object-widget.people.person_test_same_parent'
)).to.equal(personArea1._id);
expect(await reportsPage.getReportDetailFieldValueByLabel(
'report.db-object-widget.people.person_test_all'
)).to.equal(personArea2._id);
});

});
32 changes: 32 additions & 0 deletions tests/e2e/default/enketo/forms/db-object-widget.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?xml version="1.0"?>
<h:html xmlns="http://www.w3.org/2002/xforms" xmlns:h="http://www.w3.org/1999/xhtml" xmlns:ev="http://www.w3.org/2001/xml-events" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:jr="http://openrosa.org/javarosa" xmlns:orx="http://openrosa.org/xforms">
<h:head>
<h:title>DB Object Form</h:title>
<model>
<instance>
<db_object_form id="db_object_form" prefix="J1!db_object_form!" delimiter="#" version="2021-12-01 00:00:00">
<people>
<person_test_same_parent/>
<person_test_all/>
</people>
<meta tag="hidden">
<instanceID/>
</meta>
</db_object_form>
</instance>
<bind nodeset="/db_object_form/people/person_test_same_parent" type="string"/>
<bind nodeset="/db_object_form/people/person_test_all" type="string"/>
<bind nodeset="/db_object_form/meta/instanceID" type="string" readonly="true()" calculate="concat('uuid:', uuid())"/>
</model>
</h:head>
<h:body class="pages">
<group appearance="field-list" ref="/db_object_form/people">
<input ref="/db_object_form/people/person_test_same_parent" appearance="select-contact type-person descendant-of-current-contact">
<label>Select a person from same parent</label>
</input>
<input ref="/db_object_form/people/person_test_all" appearance="select-contact type-person">
<label>Select a person from all</label>
</input>
</group>
</h:body>
</h:html>
23 changes: 23 additions & 0 deletions tests/page-objects/default/enketo/generic-form.wdio.page.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,28 @@ const selectYesNoOption = async (selector, value = 'yes') => {
return value === 'yes';
};

const getDBObjectWidgetValues = async (field) => {
const widget = $(`[data-contains-ref-target="${field}"] .selection`);
await (await widget).waitForClickable();
await (await widget).click();

const dropdown = $('.select2-dropdown--below');
await (await dropdown).waitForDisplayed();
const firstElement = $('.select2-results__options > li');
await (await firstElement).waitForClickable();

const list = await $$('.select2-results__options > li');
const contacts = [];
for (const item of list) {
contacts.push({
name: await (item.$('.name').getText()),
click: () => item.click(),
});
}

return contacts;
};

module.exports = {
getFormTitle,
getErrorMessage,
Expand All @@ -125,4 +147,5 @@ module.exports = {
currentFormView,
formTitle,
selectYesNoOption,
getDBObjectWidgetValues,
};
3 changes: 2 additions & 1 deletion webapp/src/js/enketo/widgets/db-object-widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ const construct = ( element ) => {
}
const contactTypes = getContactTypes($question, $textInput);
const allowNew = $question.hasClass('or-appearance-allow-new');
Select2Search.init($selectInput, contactTypes, { allowNew }).then(function() {
const filterByParent = $question.hasClass('or-appearance-descendant-of-current-contact');
Select2Search.init($selectInput, contactTypes, { allowNew, filterByParent }).then(function() {
// select2 doesn't understand readonly
$selectInput.prop('disabled', $textInput.prop('readonly'));
});
Expand Down
6 changes: 3 additions & 3 deletions webapp/src/ts/modules/contacts/contacts.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { AuthService } from '@mm-services/auth.service';
import { SettingsService } from '@mm-services/settings.service';
import { UHCSettingsService } from '@mm-services/uhc-settings.service';
import { Selectors } from '@mm-selectors/index';
import { SearchService } from '@mm-services/search.service';
import { Filter, SearchService } from '@mm-services/search.service';
import { ContactTypesService } from '@mm-services/contact-types.service';
import { RelativeDateService } from '@mm-services/relative-date.service';
import { ScrollLoaderProvider } from '@mm-providers/scroll-loader.provider';
Expand Down Expand Up @@ -44,8 +44,8 @@ export class ContactsComponent implements OnInit, AfterViewInit, OnDestroy {
error;
appending: boolean;
hasContacts = true;
filters:any = {};
defaultFilters:any = {};
filters: Filter = {};
defaultFilters: Filter = {};
moreItems;
usersHomePlace;
contactTypes;
Expand Down
4 changes: 2 additions & 2 deletions webapp/src/ts/modules/reports/reports.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { GlobalActions } from '@mm-actions/global';
import { ReportsActions } from '@mm-actions/reports';
import { ServicesActions } from '@mm-actions/services';
import { ChangesService } from '@mm-services/changes.service';
import { SearchService } from '@mm-services/search.service';
import { Filter, SearchService } from '@mm-services/search.service';
import { Selectors } from '@mm-selectors/index';
import { AddReadStatusService } from '@mm-services/add-read-status.service';
import { ExportService } from '@mm-services/export.service';
Expand Down Expand Up @@ -52,7 +52,7 @@ export class ReportsComponent implements OnInit, AfterViewInit, OnDestroy {
loading = true;
appending = false;
moreItems: boolean;
filters:any = {};
filters: Filter = {};
hasReports: boolean;
selectMode = false;
selectModeAvailable = false;
Expand Down
Loading

0 comments on commit 4547885

Please sign in to comment.