From c7fbcb1b88129d9df4ca1cedf1d2a599803acd10 Mon Sep 17 00:00:00 2001 From: Ben Kiarie Date: Thu, 20 Jun 2024 14:48:53 +0300 Subject: [PATCH] feat(#9116): update user place field in admin to allow setting multiple places (#9128) --- admin/src/js/controllers/edit-user.js | 119 ++++++++++++---- admin/src/js/services/contact-types.js | 5 + admin/src/js/services/create-user.js | 5 +- admin/src/js/services/select2-search.js | 127 +++++++++++++++--- admin/src/templates/edit_user.html | 12 +- .../tests/unit/controllers/edit-user.spec.js | 103 ++++++++++++-- admin/tests/unit/services/update-user.spec.js | 2 +- .../translations/messages-en.properties | 4 +- .../translations/messages-es.properties | 2 + .../translations/messages-fr.properties | 2 + .../translations/messages-ne.properties | 2 + .../translations/messages-sw.properties | 2 + shared-libs/contact-types-utils/src/index.js | 6 + shared-libs/contact-types-utils/test/index.js | 39 ++++++ .../delete-assigned-place.wdio-spec.js | 73 ++++++++++ .../edit-person-home-place.wdio-spec.js | 2 +- .../contacts/person-under-area.wdio-spec.js | 2 +- tests/e2e/default/users/add-user.wdio-spec.js | 44 +++++- .../integration/api/controllers/users.spec.js | 2 +- .../default/users/user.wdio.page.js | 38 +++++- .../contacts-more-menu.component.html | 2 +- .../contacts/contacts-more-menu.component.ts | 9 ++ .../ts/modules/contacts/contacts.component.ts | 11 +- 23 files changed, 533 insertions(+), 80 deletions(-) create mode 100644 tests/e2e/default/contacts/delete-assigned-place.wdio-spec.js diff --git a/admin/src/js/controllers/edit-user.js b/admin/src/js/controllers/edit-user.js index 7e84ab66f57..01ec48af231 100644 --- a/admin/src/js/controllers/edit-user.js +++ b/admin/src/js/controllers/edit-user.js @@ -1,6 +1,8 @@ const moment = require('moment'); const passwordTester = require('simple-password-tester'); const phoneNumber = require('@medic/phone-number'); +const cht = require('@medic/cht-datasource'); +const chtDatasource = cht.getDatasource(cht.getRemoteDataContext()); const PASSWORD_MINIMUM_LENGTH = 8; const PASSWORD_MINIMUM_SCORE = 50; const USERNAME_ALLOWED_CHARS = /^[a-z0-9_-]+$/; @@ -66,6 +68,7 @@ angular // If $scope.model === {}, we're creating a new user. return Settings() .then(settings => { + $scope.permissions = settings.permissions; $scope.roles = settings.roles; $scope.allowTokenLogin = allowTokenLogin(settings); if (!$scope.model) { @@ -89,7 +92,7 @@ angular phone: $scope.model.phone, // FacilitySelect is what binds to the select, place is there to // compare to later to see if it's changed once we've run computeFields(); - facilitySelect: $scope.model.facility_id, + facilitySelect: $scope.model.facility_id || [], place: $scope.model.facility_id, roles: getRoles($scope.model.roles), // ^ Same with contactSelect vs. contact @@ -100,6 +103,23 @@ angular }); }; + const fetchDocsByIds = (ids) => { + return DB() + .allDocs({ keys: ids, include_docs: true }); + }; + + const usersPlaces = (ids) => { + return fetchDocsByIds(ids) + .then((docs) => processDocs(docs)) + .then((filteredDocs) => filteredDocs.map((doc) => doc._id)); + }; + + const processDocs = function (result) { + return result.rows + .filter((row) => row.doc && !row.value.deleted) + .map((row) => row.doc); + }; + this.setupPromise = determineEditUserModel() .then(model => { $scope.editUserModel = model; @@ -115,15 +135,16 @@ angular const personTypes = contactTypes.filter(type => type.person).map(type => type.id); Select2Search($('#edit-user-profile [name=contactSelect]'), personTypes); const placeTypes = contactTypes.filter(type => !type.person).map(type => type.id); - Select2Search($('#edit-user-profile [name=facilitySelect]'), placeTypes); + return usersPlaces($scope.editUserModel.facilitySelect).then(facilityIds => { + Select2Search($('#edit-user-profile [name=facilitySelect]'), placeTypes, { initialValue: facilityIds }); + }); }); const validateRequired = (fieldName, fieldDisplayName) => { if (!$scope.editUserModel[fieldName]) { - Translate.fieldIsRequired(fieldDisplayName) - .then(function(value) { - $scope.errors[fieldName] = value; - }); + Translate.fieldIsRequired(fieldDisplayName).then(function (value) { + $scope.errors[fieldName] = value; + }); return false; } return true; @@ -232,6 +253,24 @@ angular return true; }; + const validatePlacesPermission = () => { + if (!$scope.editUserModel.place || $scope.editUserModel.place.length <= 1) { + return true; + } + + const userHasPermission = chtDatasource.v1.hasPermissions( + ['can_have_multiple_places'], $scope.editUserModel.roles, $scope.permissions + ); + + if (!userHasPermission) { + $translate('permission.description.can_have_multiple_places.not_allowed').then(value => { + $scope.errors.multiFacility = value; + }); + } + return userHasPermission; + }; + + const isOnlineUser = (roles) => { if (!$scope.roles) { return true; @@ -254,24 +293,55 @@ angular return hasPlace && hasContact; }; + const validateFacilityHierarchy = () => { + const placeIds = $scope.editUserModel.place; + + if (!placeIds || placeIds.length === 1) { + return $q.resolve(true); + } + + return fetchDocsByIds(placeIds) + .then(result => { + const places = result.rows.map(row => row.doc); + const isSameHierarchy = ContactTypes.isSameContactType(places); + + if (!isSameHierarchy) { + $translate('permission.description.can_have_multiple_places.incompatible_place').then(value => { + $scope.errors.multiFacility = value; + }); + } + return isSameHierarchy; + }) + .catch(err => { + $log.error('Error validating facility hierarchy', err); + return false; + }); + }; + const validateContactIsInPlace = () => { - const placeId = $scope.editUserModel.place; + const placeIds = $scope.editUserModel.place; const contactId = $scope.editUserModel.contact; - if (!placeId || !contactId) { + if (!placeIds || !contactId) { return $q.resolve(true); } - return DB() - .get(contactId) - .then(function(contact) { - let parent = contact.parent; - let valid = false; - while (parent) { - if (parent._id === placeId) { - valid = true; - break; - } - parent = parent.parent; - } + + const getParent = (contactId) => { + return DB().get(contactId).then(contact => contact.parent); + }; + + const checkParent = (parent, placeIds) => { + if (!parent) { + return false; + } + if (placeIds.includes(parent._id)) { + return true; + } + return checkParent(parent.parent, placeIds); + }; + + return getParent(contactId) + .then(function (parent) { + const valid = checkParent(parent, placeIds); if (!valid) { $translate('configuration.user.place.contact').then(value => { $scope.errors.contact = value; @@ -371,9 +441,8 @@ angular }; const computeFields = () => { - $scope.editUserModel.place = $( - '#edit-user-profile [name=facilitySelect]' - ).val(); + const placeValue = $('#edit-user-profile [name=facilitySelect]').val(); + $scope.editUserModel.place = Array.isArray(placeValue) && placeValue.length === 0 ? null : placeValue; $scope.editUserModel.contact = $( '#edit-user-profile [name=contactSelect]' ).val(); @@ -449,7 +518,8 @@ angular validateRole() && validateContactAndFacility() && validatePasswordForEditUser() && - validateEmailAddress(); + validateEmailAddress() && + validatePlacesPermission(); if (!synchronousValidations) { $scope.setError(); @@ -458,6 +528,7 @@ angular const asynchronousValidations = $q .all([ + validateFacilityHierarchy(), validateContactIsInPlace(), validateTokenLogin(), ]) diff --git a/admin/src/js/services/contact-types.js b/admin/src/js/services/contact-types.js index c063928886a..068a5c24396 100644 --- a/admin/src/js/services/contact-types.js +++ b/admin/src/js/services/contact-types.js @@ -49,6 +49,11 @@ angular.module('inboxServices').service('ContactTypes', function( */ getPlaceTypes: () => Settings().then(config => contactTypesUtils.getPlaceTypes(config)), + /** + * @returns {boolean} returns whether the provided places have the same contact_type + */ + isSameContactType: (places) => contactTypesUtils.isSameContactType(places), + /** * Returns a Promise to resolve all the configured person contact types */ diff --git a/admin/src/js/services/create-user.js b/admin/src/js/services/create-user.js index b0129901b05..4ac649194e1 100644 --- a/admin/src/js/services/create-user.js +++ b/admin/src/js/services/create-user.js @@ -1,7 +1,6 @@ (function () { 'use strict'; - const URL = '/api/v2/users'; angular.module('services').factory('CreateUser', function ( @@ -20,6 +19,7 @@ * @param {Object} updates Updates you wish to make */ const createSingleUser = (updates) => { + const URL = '/api/v3/users'; if (!updates.username) { return $q.reject('You must provide a username to create a user'); } @@ -43,6 +43,7 @@ * @param {Object} data content of the csv file */ const createMultipleUsers = (data) => { + const URL = '/api/v2/users'; $log.debug('CreateMultipleUsers', URL, data); return $http({ @@ -61,6 +62,6 @@ createMultipleUsers }; }); - + }() ); diff --git a/admin/src/js/services/select2-search.js b/admin/src/js/services/select2-search.js index 95fec28dd59..30ab2474873 100644 --- a/admin/src/js/services/select2-search.js +++ b/admin/src/js/services/select2-search.js @@ -6,13 +6,15 @@ angular.module('inboxServices').factory('Select2Search', function( $log, $q, + $timeout, $translate, ContactMuted, + DB, LineageModelGenerator, Search, Session, Settings - ) { + ) { //NoSONAR 'use strict'; 'ngInject'; @@ -26,18 +28,29 @@ angular.module('inboxServices').factory('Select2Search', return $(format.sender(row.doc, $translate)); }; - const defaultTemplateSelection = function(row) { - if (row.doc) { - return row.doc.name + (row.doc.muted ? ' (' + $translate.instant('contact.muted') + ')': ''); + const defaultTemplateSelection = (selection) => { + const formatRow = (row) => { + if (!row.doc) { + return row.text; + } + + let formatted = row.doc.name; + if (row.doc.muted) { + formatted += `(${$translate.instant('contact.muted')})`; + } + return formatted; + }; + if (Array.isArray(selection)) { + return selection.map((row) => formatRow(row)).join(', '); } - return row.text; + return formatRow(selection); }; const defaultSendMessageExtras = function(row) { return row; }; - return function(selectEl, _types, options) { + return function(selectEl, _types, options) { //NoSONAR options = options || {}; let currentQuery; @@ -114,10 +127,53 @@ angular.module('inboxServices').factory('Select2Search', }); }; - const resolveInitialValue = function(selectEl, initialValue) { + const addValue = (val) => { + if (!selectEl.children('option[value="' + val + '"]').length) { + selectEl.append($('')); selectEl.trigger('change'); + + return selectEl; }); selectEl.after(button); } @@ -185,17 +275,12 @@ angular.module('inboxServices').factory('Select2Search', e.params.data.id; if (docId) { - getDoc(docId).then(function(doc) { - selectEl.select2('data')[0].doc = doc; - selectEl.trigger('change'); - }) - .catch(err => $log.error('Select2 failed to get document', err)); + updateDocument(selectEl, docId); } }); } }; - initSelect2(selectEl); return resolveInitialValue(selectEl, initialValue); }; diff --git a/admin/src/templates/edit_user.html b/admin/src/templates/edit_user.html index 6c2aa32844b..ad5cd677e5c 100644 --- a/admin/src/templates/edit_user.html +++ b/admin/src/templates/edit_user.html @@ -55,13 +55,15 @@ {{errors.roles}} -
+
- + - {{errors.place}} - user.place.help + {{errors.place}} + {{errors.multiFacility}} + user.place.help
diff --git a/admin/tests/unit/controllers/edit-user.spec.js b/admin/tests/unit/controllers/edit-user.spec.js index 802cdd04b98..a10a725bfa8 100644 --- a/admin/tests/unit/controllers/edit-user.spec.js +++ b/admin/tests/unit/controllers/edit-user.spec.js @@ -8,6 +8,7 @@ describe('EditUserCtrl controller', () => { let mockEditCurrentUser; let scope; let dbGet; + let dbAllDocs; let UpdateUser; let CreateUser; let UserSettings; @@ -21,6 +22,7 @@ describe('EditUserCtrl controller', () => { module('adminApp'); dbGet = sinon.stub(); + dbAllDocs = sinon.stub(); UpdateUser = sinon.stub().resolves(); CreateUser = { createSingleUser: sinon.stub().resolves() @@ -29,10 +31,14 @@ describe('EditUserCtrl controller', () => { Settings = sinon.stub().resolves({ roles: { 'district-manager': { name: 'xyz', offline: true }, + 'community-health-assistant': { name: 'xyz', offline: true }, 'data-entry': { name: 'abc' }, supervisor: { name: 'qrt', offline: true }, 'national-manager': { name: 'national-manager', offline: false }, - } + }, + permissions: { + can_have_multiple_places: ['community-health-assistant'], + }, }); http = { get: sinon.stub() }; userToEdit = { @@ -41,7 +47,7 @@ describe('EditUserCtrl controller', () => { fullname: 'user.fullname', email: 'user@email.com', phone: 'user.phone', - facility_id: 'abc', + facility_id: ['abc'], contact_id: 'xyz', roles: [ 'district-manager', 'supervisor' ], language: 'zz', @@ -64,6 +70,7 @@ describe('EditUserCtrl controller', () => { 'DB', KarmaUtils.mockDB({ get: dbGet, + allDocs: dbAllDocs, }) ); $provide.value('UpdateUser', UpdateUser); @@ -348,6 +355,24 @@ describe('EditUserCtrl controller', () => { }); }); + it('should allow only user with permission to have multiple places', () => { + return mockEditAUser(userToEdit) + .setupPromise.then(() => { + mockContact(userToEdit.contact_id); + mockFacility(['facility_id', 'facility_id_2']); + mockContactGet(userToEdit.contact_id); + translate.withArgs('permission.description.can_have_multiple_places.not_allowed') + .resolves('The person with selected role cannot have multiple places'); + + return scope.editUser(); + }) + .then(() => { + chai.expect(scope.errors.multiFacility).to.equal( + 'The person with selected role cannot have multiple places' + ); + }); + }); + it('user is updated', () => { mockContact(userToEdit.contact_id); @@ -363,7 +388,7 @@ describe('EditUserCtrl controller', () => { scope.editUserModel.fullname = 'fullname'; scope.editUserModel.email = 'email@email.com'; scope.editUserModel.phone = 'phone'; - scope.editUserModel.facilitySelect = 'facility_id'; + scope.editUserModel.facilitySelect = ['facility_id']; scope.editUserModel.contactSelect = 'contact_id'; scope.editUserModel.password = 'medic.1234'; scope.editUserModel.passwordConfirm = 'medic.1234'; @@ -397,6 +422,64 @@ describe('EditUserCtrl controller', () => { }); }); + it('user is updated with multiple places', () => { + mockContact(userToEdit.contact_id); + mockFacility(['facility_id', 'facility_id_2']); + mockContactGet(userToEdit.contact_id); + http.get.withArgs('/api/v1/users-info').resolves({ + data: { total_docs: 20000, warn_docs: 800, warn: false, limit: 10000 }, + }); + + dbAllDocs.resolves({ + rows: [ + { doc: { _id: 'facility_id' } }, + { doc: { _id: 'facility_id_2' } }, + ], + }); + + return mockEditAUser(userToEdit) + .setupPromise.then(() => { + scope.editUserModel.fullname = 'fullname'; + scope.editUserModel.email = 'email@email.com'; + scope.editUserModel.phone = 'phone'; + scope.editUserModel.facilitySelect = ['facility_id', 'facility_id_2']; + scope.editUserModel.contactSelect = 'contact_id'; + scope.editUserModel.password = 'medic.1234'; + scope.editUserModel.passwordConfirm = 'medic.1234'; + scope.editUserModel.roles = ['community-health-assistant']; + + return scope.editUser(); + }) + .then(() => { + chai.expect(UpdateUser.called).to.equal(true); + const updateUserArgs = UpdateUser.getCall(0).args; + + chai.expect(updateUserArgs[0]).to.equal('user.name'); + + const updates = updateUserArgs[1]; + chai.expect(updates.fullname).to.equal(scope.editUserModel.fullname); + chai.expect(updates.email).to.equal(scope.editUserModel.email); + chai.expect(updates.phone).to.equal(scope.editUserModel.phone); + chai + .expect(updates.place) + .to.deep.equal(['facility_id', 'facility_id_2']); + chai.expect(updates.contact).to.equal(scope.editUserModel.contact_id); + chai.expect(updates.roles).to.deep.equal(scope.editUserModel.roles); + chai.expect(updates.password).to.deep.equal(scope.editUserModel.password); + chai.expect(http.get.callCount).to.equal(1); + chai.expect(http.get.args[0]).to.deep.equal([ + '/api/v1/users-info', + { + params: { + role: ['community-health-assistant'], + facility_id: scope.editUserModel.place, + contact_id: scope.editUserModel.contact, + }, + }, + ]); + }); + }); + it('sorts roles when saving', () => { mockContact(userToEdit.contact_id); mockFacility(userToEdit.facility_id); @@ -443,7 +526,7 @@ describe('EditUserCtrl controller', () => { scope.editUserModel.fullname = 'fullname'; scope.editUserModel.email = 'email@email.com'; scope.editUserModel.phone = 'phone'; - scope.editUserModel.facilitySelect = 'facility_id'; + scope.editUserModel.facilitySelect = ['facility_id']; scope.editUserModel.contactSelect = 'contact_id'; scope.editUserModel.password = 'medic.1234'; scope.editUserModel.passwordConfirm = 'medic.1234'; @@ -469,7 +552,7 @@ describe('EditUserCtrl controller', () => { it('should not save user if offline and is warned by users-info', () => { mockContact('new_contact_id'); - mockFacility('new_facility_id'); + mockFacility(['new_facility_id']); mockContactGet('new_facility_id'); http.get .withArgs('/api/v1/users-info') @@ -481,7 +564,7 @@ describe('EditUserCtrl controller', () => { scope.editUserModel.fullname = 'fullname'; scope.editUserModel.email = 'email@email.com'; scope.editUserModel.phone = 'phone'; - scope.editUserModel.facilitySelect = 'new_facility'; + scope.editUserModel.facilitySelect = ['new_facility']; scope.editUserModel.contactSelect = 'new_contact'; scope.editUserModel.password = 'medic.1234'; scope.editUserModel.passwordConfirm = 'medic.1234'; @@ -495,7 +578,7 @@ describe('EditUserCtrl controller', () => { chai.expect(http.get.callCount).to.equal(1); chai.expect(http.get.args[0]).to.deep.equal([ '/api/v1/users-info', - { params: { role: [ 'supervisor' ], facility_id: 'new_facility_id', contact_id: 'new_contact_id' }} + { params: { role: [ 'supervisor' ], facility_id: ['new_facility_id'], contact_id: 'new_contact_id' }} ]); chai.expect(scope.setError.callCount).to.equal(1); chai.expect(scope.setError.args[0]).to.deep.equal([ @@ -515,7 +598,7 @@ describe('EditUserCtrl controller', () => { it('should save user if offline and warned when user clicks on submit the 2nd time', () => { mockContact('new_contact_id'); - mockFacility('new_facility_id'); + mockFacility(['new_facility_id']); mockContactGet('new_facility_id'); http.get .withArgs('/api/v1/users-info') @@ -528,7 +611,7 @@ describe('EditUserCtrl controller', () => { scope.editUserModel.fullname = 'fullname'; scope.editUserModel.email = 'email@email.com'; scope.editUserModel.phone = 'phone'; - scope.editUserModel.facilitySelect = 'new_facility'; + scope.editUserModel.facilitySelect = ['new_facility']; scope.editUserModel.contactSelect = 'new_contact'; scope.editUserModel.password = 'medic.1234'; scope.editUserModel.passwordConfirm = 'medic.1234'; @@ -541,7 +624,7 @@ describe('EditUserCtrl controller', () => { chai.expect(http.get.callCount).to.equal(1); chai.expect(http.get.args[0]).to.deep.equal([ '/api/v1/users-info', - { params: { role: [ 'supervisor' ], facility_id: 'new_facility_id', contact_id: 'new_contact_id' }} + { params: { role: [ 'supervisor' ], facility_id: ['new_facility_id'], contact_id: 'new_contact_id' }} ]); chai.expect(translate.callCount).to.equal(1); diff --git a/admin/tests/unit/services/update-user.spec.js b/admin/tests/unit/services/update-user.spec.js index 1b5d0aba7d3..62d25185931 100644 --- a/admin/tests/unit/services/update-user.spec.js +++ b/admin/tests/unit/services/update-user.spec.js @@ -82,7 +82,7 @@ describe('CreateUser service', () => { chai.expect($http.callCount).to.equal(1); chai.expect($http.args[0][0]).to.deep.equal({ method: 'POST', - url: '/api/v2/users', + url: '/api/v3/users', data: {username: 'user', some: 'updates'}, headers: { 'Accept': 'application/json', diff --git a/api/resources/translations/messages-en.properties b/api/resources/translations/messages-en.properties index 8b1155bd8e8..4962d4f1738 100644 --- a/api/resources/translations/messages-en.properties +++ b/api/resources/translations/messages-en.properties @@ -790,7 +790,7 @@ instance.upgrade.error.get_upgrade = Error fetching upgrading progress instance.upgrade.error.version_fetch = Error fetching available versions instance.upgrade.feature_releases = Feature Releases Betas instance.upgrade.install = Install -instance.upgrade.interrupted = An unexpected server error caused the upgrade to be interrupted. +instance.upgrade.interrupted = An unexpected server error caused the upgrade to be interrupted. instance.upgrade.no_betas = There are no new betas you can upgrade to. instance.upgrade.no_branches = No build branches available. instance.upgrade.no_details = (details unavailable) @@ -1027,6 +1027,8 @@ permission.description.can_view_unallocated_data_records = Allowed to see report permission.description.can_view_users = Allowed to get a list of all configured users. permission.description.can_write_wealth_quintiles = Allowed to update contacts with their wealth quintiles. permission.description.can_upgrade = Allowed to upgrade the CHT Core Framework version via the API or admin interface. +permission.description.can_have_multiple_places.not_allowed = The selected roles do not have permission to be assigned multiple places. +permission.description.can_have_multiple_places.incompatible_place = The selected places must be of the same type (be at the same hierarchy level). permissions = Permissions person.field.alternate_phone = Alternative phone number person.field.code = Code diff --git a/api/resources/translations/messages-es.properties b/api/resources/translations/messages-es.properties index f0eaab8b01b..bf8fc983850 100644 --- a/api/resources/translations/messages-es.properties +++ b/api/resources/translations/messages-es.properties @@ -1027,6 +1027,8 @@ permission.description.can_view_unallocated_data_records = Ver informes que no t permission.description.can_view_users = Ver la lista de todos los usuarios configurados. permission.description.can_write_wealth_quintiles = Actualizar contactos con sus quintiles de riqueza. permission.description.can_upgrade = Actualizar la versión de CHT Core Framework a través de la API o en la Gestión de la Aplicación. +permission.description.can_have_multiple_places.not_allowed = Los roles seleccionados no tienen permiso para ser asignados a múltiples lugares. +permission.description.can_have_multiple_places.incompatible_place = Los lugares seleccionados deben ser del mismo tipo (ser del mismo nivel de jerarquía). permissions = Permisos person.field.alternate_phone = Número de teléfono alternativo person.field.code = Código diff --git a/api/resources/translations/messages-fr.properties b/api/resources/translations/messages-fr.properties index 5926ed1b1c7..92431b1333d 100644 --- a/api/resources/translations/messages-fr.properties +++ b/api/resources/translations/messages-fr.properties @@ -1027,6 +1027,8 @@ permission.description.can_view_unallocated_data_records = Autorisé à voir les permission.description.can_view_users = Autorisé à obtenir une liste de tous les utilisateurs configurés. permission.description.can_write_wealth_quintiles = Autorisé à mettre à jour les contacts avec leurs quintiles de richesse. permission.description.can_upgrade = Autorisé à mettre à niveau la version de CHT Core Framework via l'API ou l'interface d'administration. +permission.description.can_have_multiple_places.not_allowed = Les rôles sélectionnés ne sont pas autorisés à se voir attribuer plusieurs places. +permission.description.can_have_multiple_places.incompatible_place = Les places sélectionnées doivent être du même type (être au même niveau hiérarchique). permissions = Permissions person.field.alternate_phone = Numéro de téléphone alternatif person.field.code = Code diff --git a/api/resources/translations/messages-ne.properties b/api/resources/translations/messages-ne.properties index e16a1ec10a5..0ad3076b698 100644 --- a/api/resources/translations/messages-ne.properties +++ b/api/resources/translations/messages-ne.properties @@ -949,6 +949,8 @@ permission.description.can_view_tasks_tab = permission.description.can_view_unallocated_data_records = permission.description.can_view_users = permission.description.can_write_wealth_quintiles = +permission.description.can_have_multiple_places.not_allowed = तपाईंले छानेको भूमिकाहरुलाई धेरै स्थानको प्रमुख बनाउन मिल्दैन. +permission.description.can_have_multiple_places.incompatible_place = तपाईंले छानेका स्थानहरु एकै प्रकारको हुनु पर्छ (एकै तहमा भएको)।. permissions = person.field.alternate_phone = वैकल्पिक फोन नम्बर person.field.code = कोड diff --git a/api/resources/translations/messages-sw.properties b/api/resources/translations/messages-sw.properties index d7e2c7204f2..2843aae8213 100644 --- a/api/resources/translations/messages-sw.properties +++ b/api/resources/translations/messages-sw.properties @@ -1027,6 +1027,8 @@ permission.description.can_view_unallocated_data_records = Inaruhusiwa kuona rip permission.description.can_view_users = Inaruhusiwa kupata orodha ya watumiaji wote waliosanidiwa. permission.description.can_write_wealth_quintiles = Inaruhusiwa kusasisha watu na viwango vyao vya utajiri. permission.description.can_upgrade = Inaruhusiwa kuboresha toleo la CHT kupitia API au kiolesura cha msimamizi. +permission.description.can_have_multiple_places.not_allowed = Mtu aliye na majukumu yaliyochaguliwa hana ruhusa ya kupewa maeneo mengi. +permission.description.can_have_multiple_places.incompatible_place = Maeneo yaliyochaguliwa lazima yawe ya aina moja (yawe katika kiwango sawa cha uongozi). permissions = Ruhusa person.field.alternate_phone = Nambari ya simu mbadala person.field.code = Kanuni diff --git a/shared-libs/contact-types-utils/src/index.js b/shared-libs/contact-types-utils/src/index.js index d877183f30e..6a829002bcd 100644 --- a/shared-libs/contact-types-utils/src/index.js +++ b/shared-libs/contact-types-utils/src/index.js @@ -67,6 +67,11 @@ const isPlace = (config, contact) => { return isPlaceType(type); }; +const isSameContactType = (contacts) => { + const contactTypes = new Set(contacts.map(contact => getTypeId(contact))); + return contactTypes.size === 1; +}; + const isHardcodedType = type => HARDCODED_TYPES.includes(type); const isOrphan = (type) => !type.parents || !type.parents.length; @@ -93,6 +98,7 @@ module.exports = { getContactType, isPerson, isPlace, + isSameContactType, isHardcodedType, HARDCODED_TYPES, getContactTypes, diff --git a/shared-libs/contact-types-utils/test/index.js b/shared-libs/contact-types-utils/test/index.js index 7c56002eb38..6850c28db89 100644 --- a/shared-libs/contact-types-utils/test/index.js +++ b/shared-libs/contact-types-utils/test/index.js @@ -155,6 +155,45 @@ describe('ContactType Utils', () => { }); }); + describe('isSameContactType', () => { + it('should return true for hardcoded contacts of the same type', () => { + chai.expect(utils.isSameContactType([ + { type: 'contact', contact_type: 'health_center' }, + { type: 'contact', contact_type: 'health_center' }, + ])).to.equal(true); + }); + it('should return true for configurable contacts of the same type', () => { + chai.expect(utils.isSameContactType([ + { type: 'my_health_center' }, + { type: 'my_health_center' }, + ])).to.equal(true); + }); + it('should return true for a mix of hardcoded and configurable types of the same hierarchy', () => { + chai.expect(utils.isSameContactType([ + { type: 'health_center' }, + { type: 'contact', contact_type: 'health_center' }, + ])).to.equal(true); + }); + it('should return false for hardcoded contacts of different type', () => { + chai.expect(utils.isSameContactType([ + { type: 'contact', contact_type: 'health_center' }, + { type: 'contact', contact_type: 'district_hospital' }, + ])).to.equal(false); + }); + it('should return false for configurable contacts of different type', () => { + chai.expect(utils.isSameContactType([ + { type: 'my_health_center' }, + { type: 'health_center' }, + ])).to.equal(false); + }); + it('should return true for a mix of hardcoded and configurable types of the same hierarchy', () => { + chai.expect(utils.isSameContactType([ + { type: 'health_center' }, + { type: 'contact', contact_type: 'my_health_center' }, + ])).to.equal(false); + }); + }); + describe('isPlaceType', () => { it('should return false for no type', () => { chai.expect(utils.isPlaceType(false)).to.equal(false); diff --git a/tests/e2e/default/contacts/delete-assigned-place.wdio-spec.js b/tests/e2e/default/contacts/delete-assigned-place.wdio-spec.js new file mode 100644 index 00000000000..ebed922623f --- /dev/null +++ b/tests/e2e/default/contacts/delete-assigned-place.wdio-spec.js @@ -0,0 +1,73 @@ +const utils = require('@utils'); +const usersAdminPage = require('@page-objects/default/users/user.wdio.page'); +const adminPage = require('@page-objects/default/admin/admin.wdio.page'); +const commonPage = require('@page-objects/default/common/common.wdio.page'); +const contactPage = require('@page-objects/default/contacts/contacts.wdio.page'); +const loginPage = require('@page-objects/default/login/login.wdio.page'); + +const placeFactory = require('@factories/cht/contacts/place'); +const personFactory = require('@factories/cht/contacts/person'); + +const offlineUserRole = 'chw'; +const username = 'jackuser'; +const password = 'Jacktest@123'; +const places = placeFactory.generateHierarchy(); +const districtHospital = places.get('district_hospital'); +const districtHospital2 = placeFactory.place().build({ + name: 'district_hospital', + type: 'district_hospital', +}); + +const person = personFactory.build({ + parent: { + _id: districtHospital._id, + parent: districtHospital.parent, + }, + roles: [offlineUserRole], +}); + +const docs = [...places.values(), person, districtHospital2]; + +describe('User Test Cases ->', () => { + before(async () => { + const settings = await utils.getSettings(); + const permissions = { + ...settings.permissions, + can_have_multiple_places: [offlineUserRole], + }; + await utils.updateSettings({ permissions }, true); + await utils.saveDocs(docs); + await loginPage.cookieLogin(); + }); + + beforeEach(async () => { + if (await usersAdminPage.addUserDialog().isDisplayed()) { + await usersAdminPage.closeAddUserDialog(); + } + await usersAdminPage.goToAdminUser(); + await usersAdminPage.openAddUserDialog(); + }); + + describe('Creating Users ->', () => { + after(async () => await utils.deleteUsers([{ username: username }])); + + it('should add user with multiple places with permission', async () => { + await usersAdminPage.inputAddUserFields( + username, + 'Jack', + offlineUserRole, + [districtHospital.name, districtHospital2.name], + person.name, + password + ); + await usersAdminPage.saveUser(); + await adminPage.logout(); + await loginPage.login({ username, password }); + await commonPage.goToPeople(); + await contactPage.selectLHSRowByText(districtHospital2.name); + await commonPage.openMoreOptionsMenu(); + + expect(await commonPage.isMenuOptionEnabled('delete', 'contacts')).to.be.false; + }); + }); +}); diff --git a/tests/e2e/default/contacts/edit-person-home-place.wdio-spec.js b/tests/e2e/default/contacts/edit-person-home-place.wdio-spec.js index ca4d102fd44..36643469153 100644 --- a/tests/e2e/default/contacts/edit-person-home-place.wdio-spec.js +++ b/tests/e2e/default/contacts/edit-person-home-place.wdio-spec.js @@ -6,7 +6,7 @@ const placeFactory = require('@factories/cht/contacts/place'); const userFactory = require('@factories/cht/users/users'); const personFactory = require('@factories/cht/contacts/person'); -describe.skip('Edit Person Under Area', () => { +describe('Edit Person Under Area', () => { const places = placeFactory.generateHierarchy(); const healthCenter = places.get('health_center'); diff --git a/tests/e2e/default/contacts/person-under-area.wdio-spec.js b/tests/e2e/default/contacts/person-under-area.wdio-spec.js index 0bb93d28626..3e634a0cc60 100644 --- a/tests/e2e/default/contacts/person-under-area.wdio-spec.js +++ b/tests/e2e/default/contacts/person-under-area.wdio-spec.js @@ -45,7 +45,7 @@ const person2 = personFactory.build( const docs = [...places.values(), healthCenter2, person1, person2]; -describe.skip('Create Person Under Area', () => { +describe('Create Person Under Area', () => { before(async () => { await utils.saveDocs(docs); await loginPage.cookieLogin(); diff --git a/tests/e2e/default/users/add-user.wdio-spec.js b/tests/e2e/default/users/add-user.wdio-spec.js index 364806d6dbc..07b96112524 100644 --- a/tests/e2e/default/users/add-user.wdio-spec.js +++ b/tests/e2e/default/users/add-user.wdio-spec.js @@ -11,21 +11,33 @@ const password = 'Jacktest@123'; const incorrectpassword = 'Passwor'; const places = placeFactory.generateHierarchy(); const districtHospital = places.get('district_hospital'); +const districtHospital2 = placeFactory.place().build({ + name: 'district_hospital', + type: 'district_hospital', +}); const person = personFactory.build( { parent: { _id: districtHospital._id, parent: districtHospital.parent - } + }, + roles: [offlineUserRole] } ); -const docs = [...places.values(), person]; + +const docs = [...places.values(), person, districtHospital2]; describe('User Test Cases ->', () => { before(async () => { + const settings = await utils.getSettings(); + const permissions = { + ...settings.permissions, + can_have_multiple_places: [offlineUserRole], + }; + await utils.updateSettings({ permissions }, true); await utils.saveDocs(docs); await loginPage.cookieLogin(); }); @@ -54,6 +66,19 @@ describe('User Test Cases ->', () => { await usersAdminPage.saveUser(); expect(await usersAdminPage.getAllUsernames()).to.include.members([username]); }); + + it('should add user with multiple places with permission', async () => { + await usersAdminPage.inputAddUserFields( + 'new_jack', + 'Jack', + offlineUserRole, + [districtHospital.name, districtHospital2.name], + person.name, + password + ); + await usersAdminPage.saveUser(); + expect(await usersAdminPage.getAllUsernames()).to.include.members([username]); + }); }); describe('Invalid entries -> ', () => { @@ -94,5 +119,20 @@ describe('User Test Cases ->', () => { expect(await usersAdminPage.getPlaceErrorText()).to.contain('required'); expect(await usersAdminPage.getContactErrorText()).to.contain('required'); }); + + it('should require user to have permission for multiple places', async () => { + await usersAdminPage.inputAddUserFields( + username, + 'Jack', + onlineUserRole, + [districtHospital.name, districtHospital2.name], + person.name, + password + ); + await usersAdminPage.saveUser(false); + expect(await usersAdminPage.getPlaceErrorText()).to.contain( + 'The selected roles do not have permission to be assigned multiple places.' + ); + }); }); }); diff --git a/tests/integration/api/controllers/users.spec.js b/tests/integration/api/controllers/users.spec.js index 8e62073b4c9..42f40668274 100644 --- a/tests/integration/api/controllers/users.spec.js +++ b/tests/integration/api/controllers/users.spec.js @@ -1716,7 +1716,7 @@ describe('Users API', () => { }); - describe.skip('POST/GET api/v2/users', () => { + describe('POST/GET api/v2/users', () => { before(async () => { await utils.saveDoc(parentPlace); }); diff --git a/tests/page-objects/default/users/user.wdio.page.js b/tests/page-objects/default/users/user.wdio.page.js index ea694406184..31cb879093f 100644 --- a/tests/page-objects/default/users/user.wdio.page.js +++ b/tests/page-objects/default/users/user.wdio.page.js @@ -48,7 +48,7 @@ const scrollToBottomOfModal = async () => { }); }; -const inputAddUserFields = async (username, fullname, role, place, contact, password, confirmPassword = password) => { +const inputAddUserFields = async (username, fullname, role, places, contact, password, confirmPassword = password) => { await (await userName()).setValue(username); await (await userFullName()).setValue(fullname); await (await $(`#role-select input[value="${role}"]`)).click(); @@ -57,8 +57,14 @@ const inputAddUserFields = async (username, fullname, role, place, contact, pass // scrollIntoView doesn't work because they're within a scrollable div (the modal) await scrollToBottomOfModal(); - if (!_.isEmpty(place)) { - await selectPlace(place); + if (!_.isEmpty(places)) { + if (Array.isArray(places)) { + for (const name of places) { + await selectPlace([name]); + } + } else { + await selectPlace([places]); + } } if (!_.isEmpty(contact)) { @@ -78,7 +84,9 @@ const setSelect2 = async (id, value) => { await input.waitForExist(); await input.click(); - const searchField = await $('.select2-search__field'); + const searchField = await $( + `.select2-container--open .select2-search__field` + ); await searchField.waitForExist(); await searchField.setValue(value); @@ -88,8 +96,26 @@ const setSelect2 = async (id, value) => { await option.click(); }; -const selectPlace = async (place) => { - await setSelect2('facilitySelect', place); +const setPlaceSelectMultiple = async (value) => { + const input = await $(`span.select2-selection--multiple`); + await input.waitForExist(); + await input.click(); + + const searchField = await $('span.select2-selection--multiple .select2-search__field'); + await searchField.waitForExist(); + await searchField.setValue(value); + + const option = await $('.name'); + await option.waitForExist(); + await option.waitForClickable(); + await option.click(); + await browser.waitUntil(async () => await (await $('.select2-selection__choice')).isDisplayed(), 1000); +}; + +const selectPlace = async (places) => { + for (const place of places) { + await setPlaceSelectMultiple(place); + } }; const selectContact = async (associatedContact) => { diff --git a/webapp/src/ts/modules/contacts/contacts-more-menu.component.html b/webapp/src/ts/modules/contacts/contacts-more-menu.component.html index f6fbb8c6b68..f80c14ddc26 100644 --- a/webapp/src/ts/modules/contacts/contacts-more-menu.component.html +++ b/webapp/src/ts/modules/contacts/contacts-more-menu.component.html @@ -12,7 +12,7 @@ {{ 'Edit' | translate }} - diff --git a/webapp/src/ts/modules/contacts/contacts-more-menu.component.ts b/webapp/src/ts/modules/contacts/contacts-more-menu.component.ts index 22ecce3b833..a21edd9e54b 100644 --- a/webapp/src/ts/modules/contacts/contacts-more-menu.component.ts +++ b/webapp/src/ts/modules/contacts/contacts-more-menu.component.ts @@ -30,6 +30,7 @@ export class ContactsMoreMenuComponent implements OnInit, OnDestroy { selectedContactDoc; hasNestedContacts = false; + isUserFacility = false; contactsList; useOldActionBar = false; @@ -70,6 +71,7 @@ export class ContactsMoreMenuComponent implements OnInit, OnDestroy { this.selectedContactDoc = selectedContactDoc; this.loadingContent = loadingContent; this.snapshotData = snapshotData; + this.checkUserFacility(); }); this.subscription.add(storeSubscription); @@ -98,6 +100,13 @@ export class ContactsMoreMenuComponent implements OnInit, OnDestroy { .catch(error => console.error('Error fetching user settings', error)); } + private checkUserFacility() { + if (this.sessionService.isAdmin()) { + return false; + } + this.isUserFacility = this.userSettings?.facility_id.includes(this.selectedContactDoc?._id); + } + deleteContact() { this.globalActions.deleteDocConfirm(this.selectedContactDoc); } diff --git a/webapp/src/ts/modules/contacts/contacts.component.ts b/webapp/src/ts/modules/contacts/contacts.component.ts index cd1a2930e0b..ce33d04159c 100644 --- a/webapp/src/ts/modules/contacts/contacts.component.ts +++ b/webapp/src/ts/modules/contacts/contacts.component.ts @@ -140,7 +140,7 @@ export class ContactsComponent implements OnInit, AfterViewInit, OnDestroy { this.contactsActions.removeContactFromList({ _id: change.id }); this.hasContacts = !!this.contactsList.length; } - if (this.usersHomePlace?.some(homePlace => homePlace._id === change.id)) { + if (this.usersHomePlace?.find(homePlace => homePlace._id === change.id)) { this.usersHomePlace = await this.getUserHomePlaceSummary(); } const withIds = @@ -236,7 +236,6 @@ export class ContactsComponent implements OnInit, AfterViewInit, OnDestroy { return; } - this.globalActions.setUserFacilityId(facilityId); return this.getDataRecordsService .get(facilityId) @@ -504,6 +503,10 @@ export class ContactsComponent implements OnInit, AfterViewInit, OnDestroy { return contact._id + contact._rev; } + private getUserHomePlaceId() { + return this.usersHomePlace?.[0]?._id; + } + private setLeftActionBar() { if (this.destroyed) { // don't update the actionbar if the component has already been destroyed @@ -514,7 +517,7 @@ export class ContactsComponent implements OnInit, AfterViewInit, OnDestroy { this.globalActions.setLeftActionBar({ exportFn: () => this.exportContacts(), hasResults: this.hasContacts, - userFacilityId: this.usersHomePlace?.[0]?._id, + userFacilityId: this.getUserHomePlaceId(), childPlaces: this.allowedChildPlaces, }); } @@ -527,7 +530,7 @@ export class ContactsComponent implements OnInit, AfterViewInit, OnDestroy { } this.fastActionList = await this.fastActionButtonService.getContactLeftSideActions({ - parentFacilityId: this.usersHomePlace?.[0]?._id, + parentFacilityId: this.getUserHomePlaceId(), childContactTypes: this.allowedChildPlaces, }); }