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(#8074): add filter by parent to db-object widget #8759

Merged
merged 16 commits into from Jan 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
97 changes: 58 additions & 39 deletions shared-libs/search/src/generate-search-requests.js
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
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
@@ -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 } });

latin-panda marked this conversation as resolved.
Show resolved Hide resolved
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
@@ -0,0 +1,32 @@
<?xml version="1.0"?>
latin-panda marked this conversation as resolved.
Show resolved Hide resolved
<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
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();
Comment on lines +117 to +118
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I was struggling with this e2e being flaky, this seems to work.


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
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
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
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