diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cc7961c43..afa15e77d 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,3 +1,5 @@ +#GUSINFO:NPC Asteroids, SFDO Program Management Module + # Python *.py @SalesforceFoundation/release-engineering-reviewers diff --git a/.gitignore b/.gitignore index 75a96fbfc..577fc12bb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Salesforce / SFDX / CCI .cci .sfdx +.sf /src.orig /src diff --git a/README.md b/README.md index 6b8f2de8d..44df95191 100644 --- a/README.md +++ b/README.md @@ -26,5 +26,4 @@ PMM AND SFDO BASE ARE NON-SFDC APPLICATIONS OR THIRD-PARTY APPLICATIONS, AND NOT SFDC WILL NOT HAVE ANY LIABILITY ARISING OUT OF OR RELATED TO YOUR USE OF PMM OR SFDO BASE FOR ANY DIRECT DAMAGES OR FOR ANY LOST PROFITS, REVENUES, GOODWILL OR INDIRECT, SPECIAL, INCIDENTAL, CONSEQUENTIAL, EXEMPLARY, COVER, BUSINESS INTERRUPTION OR PUNITIVE DAMAGES, WHETHER AN ACTION IS IN CONTRACT OR TORT AND REGARDLESS OF THE THEORY OF LIABILITY, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES OR IF A REMEDY OTHERWISE FAILS OF ITS ESSENTIAL PURPOSE. THE FOREGOING DISCLAIMER WILL NOT APPLY TO THE EXTENT PROHIBITED BY LAW. SFDC DISCLAIMS ALL LIABILITY AND INDEMNIFICATION OBLIGATIONS FOR ANY HARM OR DAMAGES CAUSED BY ANY THIRD-PARTY HOSTING PROVIDERS. -THIS AGREEMENT SHALL BE GOVERNED EXCLUSIVELY BY, AND CONSTRUED EXCLUSIVELY IN ACCORDANCE WITH, THE LAWS OF THE UNITED STATES AND THE STATE OF CALIFORNIA, WITHOUT REGARD TO ITS CONFLICT OF LAWS PROVISIONS. THE STATE AND FEDERAL COURTS LOCATED IN SAN FRANCISCO, CALIFORNIA SHALL HAVE EXCLUSIVE JURISDICTION TO ADJUDICATE ANY DISPUTE ARISING OUT OF OR RELATING TO THIS AGREEMENT. EACH PARTY HEREBY CONSENTS TO THE JURISDICTION OF SUCH COURTS AND WAIVES ANY RIGHT IT MAY OTHERWISE HAVE TO CHALLENGE THE APPROPRIATENESS OF SUCH FORUMS. - +THIS AGREEMENT SHALL BE GOVERNED EXCLUSIVELY BY, AND CONSTRUED EXCLUSIVELY IN ACCORDANCE WITH, THE LAWS OF THE UNITED STATES AND THE STATE OF CALIFORNIA, WITHOUT REGARD TO ITS CONFLICT OF LAWS PROVISIONS. THE STATE AND FEDERAL COURTS LOCATED IN SAN FRANCISCO, CALIFORNIA SHALL HAVE EXCLUSIVE JURISDICTION TO ADJUDICATE ANY DISPUTE ARISING OUT OF OR RELATING TO THIS AGREEMENT. EACH PARTY HEREBY CONSENTS TO THE JURISDICTION OF SUCH COURTS AND WAIVES ANY RIGHT IT MAY OTHERWISE HAVE TO CHALLENGE THE APPROPRIATENESS OF SUCH FORUMS. \ No newline at end of file diff --git a/cumulusci.yml b/cumulusci.yml index 153100101..5512bb6fb 100644 --- a/cumulusci.yml +++ b/cumulusci.yml @@ -1,4 +1,4 @@ -minimum_cumulusci_version: "3.36.0" +minimum_cumulusci_version: 3.74.0 project: name: PMM package: @@ -220,6 +220,11 @@ tasks: options: path: unpackaged/config/customer_profiles + github_release: + options: + release_content: | + Check out the [Salesforce Release Notes](https://sfdc.co/bnL4Cb) or [Known Issues](https://issues.salesforce.com/) for details. + flows: make_community: steps: @@ -429,6 +434,8 @@ flows: task: update_dependencies 2: task: install_managed + options: + security_type: NONE 3: task: deploy_customer_profiles ui_options: @@ -439,6 +446,11 @@ flows: reports: name: "Deploy Folder of Unmanaged Reports" + release_production: + steps: + 3: + task: None + plans: install: slug: install diff --git a/force-app/main/default/aura/addMoreParticipants/addMoreParticipants.cmp b/force-app/main/default/aura/addMoreParticipants/addMoreParticipants.cmp index 7a2abc9da..2a6afa738 100644 --- a/force-app/main/default/aura/addMoreParticipants/addMoreParticipants.cmp +++ b/force-app/main/default/aura/addMoreParticipants/addMoreParticipants.cmp @@ -11,7 +11,8 @@ .cuf-content { padding: 0 0rem !important; } .slds-p-around--medium { padding: - 0rem !important; } .slds-modal__content{ height:unset !important; max-height:unset - !important; } .slds-modal__container{ width: 80% !important; max-width: 80% !important; } + 0rem !important; } .quick-actions-panel .slds-modal__content{ height:unset + !important; max-height:unset !important; } .slds-modal__container{ width: 80% + !important; max-width: 80% !important; } diff --git a/force-app/main/default/aura/addMoreSessions/addMoreSessions.cmp b/force-app/main/default/aura/addMoreSessions/addMoreSessions.cmp index 2ed4c4eda..33e24052d 100644 --- a/force-app/main/default/aura/addMoreSessions/addMoreSessions.cmp +++ b/force-app/main/default/aura/addMoreSessions/addMoreSessions.cmp @@ -22,8 +22,8 @@ .cuf-content { padding: 0 0rem !important; } .slds-p-around--medium { padding: - 0rem !important; } .slds-modal__content{ height:unset !important; max-height:unset - !important; } .slds-modal__container{ width: 80% !important; max-width: 80% - !important; } + 0rem !important; } .quick-actions-panel .slds-modal__content{ height:unset + !important; max-height:unset !important; } .slds-modal__container{ width: 80% + !important; max-width: 80% !important; } diff --git a/force-app/main/default/aura/createServiceSchedule/createServiceSchedule.cmp b/force-app/main/default/aura/createServiceSchedule/createServiceSchedule.cmp index f86c08630..f688fca1d 100644 --- a/force-app/main/default/aura/createServiceSchedule/createServiceSchedule.cmp +++ b/force-app/main/default/aura/createServiceSchedule/createServiceSchedule.cmp @@ -23,8 +23,6 @@ .slds-accordion__list-item { border-top: none; } .slds-table_header-fixed_container { overflow-x: hidden; } .cuf-content { padding: 0 0rem !important; } .slds-p-around--medium { padding: 0rem !important; } - .slds-modal__content{ height:unset !important; max-height:unset !important; } - .slds-modal__container{ width: 80% !important; max-width: 80% !important; } queriedEngagements = [ - SELECT Id, Name, Program__c, Program__r.Name + SELECT Id, Name, Program__c, Program__r.Name, Stage__c FROM ProgramEngagement__c WHERE Contact__c = :contactId ]; - + queriedEngagements.sort(); return Security.stripInaccessible(AccessType.READABLE, queriedEngagements) .getRecords(); } + public ProgramEngagement__c getProgramEngagementById(Id peId) { + if ( + !(Schema.SObjectType.ProgramEngagement__c.isAccessible() && + PermissionValidator.getInstance() + .hasFieldReadAccess(ProgramEngagement__c.Contact__c.getDescribe()) && + PermissionValidator.getInstance() + .hasFieldReadAccess(ProgramEngagement__c.Program__c.getDescribe())) + ) { + return null; + } + + List queriedEngagements = [ + SELECT + Id, + Name, + Program__c, + Program__r.Name, + ProgramCohort__c, + Stage__c, + Contact__c, + Contact__r.Name, + Contact__r.Email + FROM ProgramEngagement__c + WHERE Id = :peId + ]; + List securityResult = Security.stripInaccessible( + AccessType.READABLE, + queriedEngagements + ) + .getRecords(); + return securityResult.isEmpty() ? null : securityResult[0]; + } + // Using strip inaccessible and performing access checks for // all fields involved in the query. Id, Name must be true if object // access is true. @@ -72,8 +105,12 @@ public with sharing class ProgramEngagementSelector { public List getProgramEngagementsByProgramId( Id programId, Set fields, - Set stages + Set stages, + String searchText, + Id cohortId ) { + final Integer LIMIT_TO = 1000; + if ( !(Schema.SObjectType.ProgramEngagement__c.isAccessible() && PermissionValidator.getInstance() @@ -84,8 +121,9 @@ public with sharing class ProgramEngagementSelector { return new List(); } - Integer limitTo = - System.Limits.getLimitQueryRows() - System.Limits.getQueryRows(); + String programEngagementName = Schema.SObjectType.ProgramEngagement__c.getName(); + List programEngagements; + Set targetIds; queryBuilder .reset() @@ -95,13 +133,97 @@ public with sharing class ProgramEngagementSelector { String.valueOf(ProgramEngagement__c.Program__c) + ' = :programId' ) .addCondition(String.valueOf(ProgramEngagement__c.Stage__c) + ' IN :stages') - .withLimit(limitTo); + .withLimit(LIMIT_TO); + if (cohortId != null) { + queryBuilder.addCondition( + String.valueOf(ProgramEngagement__c.ProgramCohort__c) + ' = :cohortId' + ); + } + if (!String.isBlank(searchText)) { + targetIds = getEngagementIdsBySearchTerm( + searchText, + fields, + programId, + stages, + cohortId + ); + queryBuilder.addCondition( + String.valueOf(ProgramEngagement__c.Id) + ' IN :targetIds' + ); + } - List programEngagements = Database.query( - queryBuilder.buildSoqlQuery() - ); + programEngagements = Database.query(queryBuilder.buildSoqlQuery()); return Security.stripInaccessible(AccessType.READABLE, programEngagements) .getRecords(); } + + private Set getEngagementIdsBySearchTerm( + String searchText, + Set fields, + Id programId, + Set stages, + Id cohortId + ) { + final Integer SEARCH_LIMIT = 1000; + searchText = String.escapeSingleQuotes(searchText); + + String peSearchString = 'FIND :searchText IN ALL FIELDS RETURNING {0}({1} {2} LIMIT :SEARCH_LIMIT)'; + String whereClause = + ' WHERE ' + + String.valueOf(ProgramEngagement__c.Program__c) + + ' = :programId' + + ' AND ' + + String.valueOf(ProgramEngagement__c.Stage__c) + + ' IN :stages '; + if (cohortId != null) { + whereClause += + ' AND ' + + String.valueOf(ProgramEngagement__c.ProgramCohort__c) + + ' =: cohortId '; + } + + String queryString = String.format( + peSearchString, + new List{ + Schema.SObjectType.ProgramEngagement__c.getName(), + String.join(new List(fields), ', '), + whereClause + } + ); + + List> engagementResult = Search.query(queryString); + List> contactResult = [ + FIND :searchText + IN ALL FIELDS + RETURNING Contact(FirstName, LastName, Email LIMIT :SEARCH_LIMIT) + ]; + + return getEngagementIdsFromSOSLResult( + programId, + engagementResult[0], + contactResult[0] + ); + } + + private Set getEngagementIdsFromSOSLResult( + Id programId, + List engagements, + List contacts + ) { + Set resultIds = new Set(); + resultIds.addAll((new Map(engagements)).keySet()); + + Set contactIds = (new Map(contacts)).keySet(); + + for (ProgramEngagement__c engagement : [ + SELECT Id + FROM ProgramEngagement__c + WHERE Contact__c IN :contactIds AND Program__c = :programId + ]) { + resultIds.add(engagement.Id); + } + + return resultIds; + } } diff --git a/force-app/main/default/classes/ProgramEngagementSelector_TEST.cls b/force-app/main/default/classes/ProgramEngagementSelector_TEST.cls index 7a51c32a1..11bf46dfb 100755 --- a/force-app/main/default/classes/ProgramEngagementSelector_TEST.cls +++ b/force-app/main/default/classes/ProgramEngagementSelector_TEST.cls @@ -74,6 +74,54 @@ public with sharing class ProgramEngagementSelector_TEST { Test.stopTest(); } + @IsTest + private static void testGetProgramEngagementsById() { + ProgramEngagement__c expected = [ + SELECT Id, Name + FROM ProgramEngagement__c + LIMIT 1 + ][0]; + + Test.startTest(); + ProgramEngagementSelector selector = new ProgramEngagementSelector(); + ProgramEngagement__c actual = selector.getProgramEngagementById(expected.Id); + Test.stopTest(); + System.assertEquals(expected.Id, actual.Id); + } + + @IsTest + private static void testGetProgramEngagementsByIdNoAccess() { + Profile p = [SELECT Id FROM Profile WHERE Name = 'Standard User']; + Integer random = Integer.valueOf(math.rint(math.random() * 1000000)); + User u = new User( + Alias = 'stand', + Email = 'standarduser2@' + random + '.example.com', + EmailEncodingKey = 'UTF-8', + LastName = 'StandardUser', + LanguageLocaleKey = 'en_US', + LocaleSidKey = 'en_US', + ProfileId = p.Id, + TimeZoneSidKey = 'America/Los_Angeles', + UserName = 'standarduser2@' + random + '.example.com' + ); + + ProgramEngagement__c engagement = [ + SELECT Id, Name + FROM ProgramEngagement__c + LIMIT 1 + ][0]; + + Test.startTest(); + System.runAs(u) { + ProgramEngagementSelector selector = new ProgramEngagementSelector(); + ProgramEngagement__c actual = selector.getProgramEngagementById( + engagement.Id + ); + System.assertEquals(null, actual); + } + Test.stopTest(); + } + @IsTest private static void testGetProgramEngagementsByProgramId() { Program__c program = [SELECT Id, Name FROM Program__c LIMIT 1]; @@ -94,7 +142,9 @@ public with sharing class ProgramEngagementSelector_TEST { 'Name', String.valueOf(ProgramEngagement__c.ProgramCohort__c) }, - ACTIVE_ENGAGEMENT_STAGES + ACTIVE_ENGAGEMENT_STAGES, + null, + null ); Test.stopTest(); System.assert( @@ -113,6 +163,107 @@ public with sharing class ProgramEngagementSelector_TEST { ); } + @IsTest + private static void testGetProgramEngagementsByProgramIdWithSearch() { + Program__c program = [SELECT Id, Name FROM Program__c LIMIT 1]; + + List expected = new List( + [ + SELECT Id, Contact__r.Name, Contact__r.Email, Stage__c, ProgramCohort__c + FROM ProgramEngagement__c + WHERE Program__c = :program.Id AND Stage__c = 'Enrolled' + ] + ); + + //Search results must be populated manually for SOSL + List soslTestIds = new List{ expected[0].Id }; + + for (Contact con : [SELECT Id, Name, Email FROM Contact]) { + soslTestIds.add(con.Id); + } + Test.setFixedSearchResults(soslTestIds); + + Test.startTest(); + + ProgramEngagementSelector selector = new ProgramEngagementSelector(); + List actual = selector.getProgramEngagementsByProgramId( + program.Id, + new Set{ + 'Name', + String.valueOf(ProgramEngagement__c.ProgramCohort__c) + }, + new Set{ 'Enrolled' }, + 'Test Contact', + null + ); + Test.stopTest(); + + System.assert( + !actual.isEmpty(), + 'Expected at least one program engagement to be returned.' + ); + System.assertEquals( + expected.size(), + actual.size(), + 'Expected that both the actual and expected list size are the same' + ); + } + + @IsTest + private static void testGetProgramEngagementsByProgramIdWithSeaerchAndCohort() { + Program__c program = [SELECT Id, Name FROM Program__c LIMIT 1]; + ProgramCohort__c cohort = [ + SELECT Id, Name + FROM ProgramCohort__c + WHERE Program__c = :program.Id + LIMIT 1 + ]; + + List expected = new List( + [ + SELECT Id, Contact__r.Name, Contact__r.Email, Stage__c, ProgramCohort__c + FROM ProgramEngagement__c + WHERE + Stage__c IN :ACTIVE_ENGAGEMENT_STAGES + AND Program__c = :program.Id + AND ProgramCohort__c = :cohort.Id + ] + ); + + //Search results must be populated manually for SOSL + List soslTestIds = new List{ expected[0].Id }; + + for (Contact con : [SELECT Id, Name, Email FROM Contact]) { + soslTestIds.add(con.Id); + } + Test.setFixedSearchResults(soslTestIds); + + Test.startTest(); + + ProgramEngagementSelector selector = new ProgramEngagementSelector(); + List actual = selector.getProgramEngagementsByProgramId( + program.Id, + new Set{ + 'Name', + String.valueOf(ProgramEngagement__c.ProgramCohort__c) + }, + ACTIVE_ENGAGEMENT_STAGES, + 'Test Contact', + cohort.Id + ); + Test.stopTest(); + + System.assert( + !actual.isEmpty(), + 'Expected at least one program engagement to be returned.' + ); + System.assertEquals( + expected.size(), + actual.size(), + 'Expected that both the actual and expected list size are the same' + ); + } + @IsTest private static void testGetProgramEngagementsByProgramIdNoAccess() { Profile p = [SELECT Id FROM Profile WHERE Name = 'Standard User']; @@ -145,7 +296,9 @@ public with sharing class ProgramEngagementSelector_TEST { List actual = selector.getProgramEngagementsByProgramId( program.Id, new Set{ 'Name' }, - ACTIVE_ENGAGEMENT_STAGES + ACTIVE_ENGAGEMENT_STAGES, + null, + null ); System.assertEquals( new List(), diff --git a/force-app/main/default/classes/ProgramEngagementService.cls b/force-app/main/default/classes/ProgramEngagementService.cls index 22ad465f6..1a004441a 100644 --- a/force-app/main/default/classes/ProgramEngagementService.cls +++ b/force-app/main/default/classes/ProgramEngagementService.cls @@ -10,6 +10,8 @@ public with sharing class ProgramEngagementService { @TestVisible private FieldBucketSelector bucketSelector = new FieldBucketSelector(); + @TestVisible + private ProgramEngagementSelector selector = new ProgramEngagementSelector(); @TestVisible private Set activeStages { @@ -43,4 +45,21 @@ public with sharing class ProgramEngagementService { return activeStages; } + + public Map getActiveStagesByValue() { + Set apiNames = activeStages; + Map result = new Map(); + Schema.DescribeFieldResult fieldResult = ProgramEngagement__c.Stage__c.getDescribe(); + List ple = fieldResult.getPicklistValues(); + for (Schema.PicklistEntry f : ple) { + if (apiNames.contains(f.getValue())) { + result.put(f.getValue(), f.getLabel()); + } + } + return result; + } + + public ProgramEngagement__c getProgramEngagementById(Id peId) { + return selector.getProgramEngagementById(peId); + } } diff --git a/force-app/main/default/classes/ProgramEngagementService_TEST.cls b/force-app/main/default/classes/ProgramEngagementService_TEST.cls index bc0fc4e7d..032aa11a5 100644 --- a/force-app/main/default/classes/ProgramEngagementService_TEST.cls +++ b/force-app/main/default/classes/ProgramEngagementService_TEST.cls @@ -10,6 +10,7 @@ @IsTest public with sharing class ProgramEngagementService_TEST { private static TestStub bucketSelectorStub; + private static TestStub peSelectorStub; private static ProgramEngagementService service = new ProgramEngagementService(); @@ -52,6 +53,49 @@ public with sharing class ProgramEngagementService_TEST { bucketSelectorStub.assertCalledAsExpected(); } + @IsTest + private static void shouldGetActiveStagesByValue() { + Schema.SObjectType programEngagementSObjType = ProgramEngagement__c.SObjectType; + Schema.SObjectField stageField = ProgramEngagement__c.Stage__c; + + Map expectedStageMap = new Map(); + expectedStageMap.put('Active', 'Active'); + expectedStageMap.put('Enrolled', 'Enrolled'); + + Test.startTest(); + Map actualStageMap = service.getActiveStagesByValue(); + Test.stopTest(); + System.assertEquals( + expectedStageMap.size(), + actualStageMap.size(), + 'Returned incorrect number of mappings' + ); + } + + @IsTest + private static void shouldGetProgramEngagementById() { + Id peId = TestUtil.mockId(ProgramEngagement__c.SObjectType); + ProgramEngagement__c expected = new ProgramEngagement__c(); + expected.Id = peId; + + peSelectorStub = new StubBuilder(ProgramEngagementSelector.class) + .when('getProgramEngagementById', Id.class) + .calledWith(peId) + .thenReturn(expected) + .build(); + service.selector = (ProgramEngagementSelector) peSelectorStub.create(); + + Test.startTest(); + ProgramEngagement__c actual = service.getProgramEngagementById(peId); + Test.stopTest(); + + System.assertEquals( + expected, + actual, + 'Did not return the expected Program Engagement' + ); + } + private static List createBuckets() { BucketedField__mdt bucketedField = (BucketedField__mdt) new TestUtil.BucketedFieldBuilder() .withDeveloperName('ProgramEngagementStage') diff --git a/force-app/main/default/classes/ProgramSelector.cls b/force-app/main/default/classes/ProgramSelector.cls index d9391c411..b0e48e172 100644 --- a/force-app/main/default/classes/ProgramSelector.cls +++ b/force-app/main/default/classes/ProgramSelector.cls @@ -41,9 +41,8 @@ public with sharing class ProgramSelector { ' IN :allowedProgramCohortStatuses' ); } - programCohorts = Database.query(queryBuilder.buildSoqlQuery()); - + programCohorts.sort(); return Security.stripInaccessible(AccessType.READABLE, programCohorts) .getRecords(); } diff --git a/force-app/main/default/classes/ServiceDeliveryController.cls b/force-app/main/default/classes/ServiceDeliveryController.cls index 1075a95ab..7306df583 100644 --- a/force-app/main/default/classes/ServiceDeliveryController.cls +++ b/force-app/main/default/classes/ServiceDeliveryController.cls @@ -33,6 +33,20 @@ public with sharing class ServiceDeliveryController { return true; } + @AuraEnabled + public static String upsertServiceDeliveries( + List serviceDeliveries, + Boolean allOrNone + ) { + try { + List results = deliveryDomain + .upsertServiceDeliveries(serviceDeliveries, allOrNone); + return JSON.serialize(results); + } catch (Exception e) { + throw Util.getAuraHandledException(e); + } + } + @AuraEnabled public static Integer deleteServiceDeliveriesForSession(Id sessionId) { try { diff --git a/force-app/main/default/classes/ServiceDeliveryController_TEST.cls b/force-app/main/default/classes/ServiceDeliveryController_TEST.cls index cb208d60b..e9f4ec572 100644 --- a/force-app/main/default/classes/ServiceDeliveryController_TEST.cls +++ b/force-app/main/default/classes/ServiceDeliveryController_TEST.cls @@ -249,6 +249,86 @@ public with sharing class ServiceDeliveryController_TEST { ); } + @IsTest + private static void testUpsertServiceDeliveries() { + List serviceDeliveries = new List{ + new ServiceDelivery__c( + Name = 'Test1', + Id = TestUtil.mockId(ServiceDelivery__c.SObjectType) + ), + new ServiceDelivery__c( + Name = 'Test2', + Id = TestUtil.mockId(ServiceDelivery__c.SObjectType) + ), + new ServiceDelivery__c( + Name = 'Test3', + Id = TestUtil.mockId(ServiceDelivery__c.SObjectType) + ) + }; + + domainStub.withReturnValue( + 'upsertServiceDeliveries', + new List{ List.class, Boolean.class }, + null + ); + + Test.startTest(); + ServiceDeliveryController.deliveryDomain = (ServiceDeliveryDomain) domainStub.createMock(); + ServiceDeliveryController.upsertServiceDeliveries(serviceDeliveries, false); + Test.stopTest(); + + domainStub.assertCalledWith( + 'upsertServiceDeliveries', + new List{ List.class, Boolean.class }, + new List{ serviceDeliveries, false } + ); + } + + @IsTest + private static void testUpsertServiceDeliveriesException() { + List serviceDeliveries = new List{ + new ServiceDelivery__c( + Name = 'Test1', + Id = TestUtil.mockId(ServiceDelivery__c.SObjectType) + ), + new ServiceDelivery__c( + Name = 'Test2', + Id = TestUtil.mockId(ServiceDelivery__c.SObjectType) + ), + new ServiceDelivery__c( + Name = 'Test3', + Id = TestUtil.mockId(ServiceDelivery__c.SObjectType) + ) + }; + + domainStub.withThrowException( + 'upsertServiceDeliveries', + new List{ List.class, Boolean.class } + ); + + Test.startTest(); + ServiceDeliveryController.deliveryDomain = (ServiceDeliveryDomain) domainStub.createMock(); + + Exception actualException; + try { + ServiceDeliveryController.upsertServiceDeliveries(serviceDeliveries, false); + } catch (Exception e) { + actualException = e; + } + Test.stopTest(); + + System.assertEquals( + domainStub.testExceptionMessage, + actualException.getMessage(), + 'Expected the controller to rethrow the exception from the domain.' + ); + domainStub.assertCalledWith( + 'upsertServiceDeliveries', + new List{ List.class, Boolean.class }, + new List{ serviceDeliveries, false } + ); + } + @IsTest private static void testGetNumberOfServiceDeliveriesForSession() { Integer expectedNumberOfDeliveries = 5; diff --git a/force-app/main/default/classes/ServiceDeliveryDomain.cls b/force-app/main/default/classes/ServiceDeliveryDomain.cls index d2a3680a9..593e72682 100644 --- a/force-app/main/default/classes/ServiceDeliveryDomain.cls +++ b/force-app/main/default/classes/ServiceDeliveryDomain.cls @@ -51,15 +51,7 @@ public with sharing class ServiceDeliveryDomain { return; } - if ( - !PermissionValidator.getInstance() - .hasObjectAccess( - ServiceDelivery__c.SObjectType, - PermissionValidator.CRUDAccessType.CREATEABLE - ) - ) { - throw new ServiceDeliveryDomainException(Label.UpsertOperationException); - } + validateInsertAccess(); insert Security.stripInaccessible(AccessType.CREATABLE, serviceDeliveries) .getRecords(); @@ -70,6 +62,47 @@ public with sharing class ServiceDeliveryDomain { return; } + validateUpdateAccess(); + + update Security.stripInaccessible(AccessType.UPDATABLE, serviceDeliveries) + .getRecords(); + } + + public List upsertServiceDeliveries( + List serviceDeliveries, + Boolean allOrNone + ) { + Boolean hasNewRecords = false; + Boolean hasExistingRecords = false; + + for (ServiceDelivery__c delivery : serviceDeliveries) { + if (!hasNewRecords && delivery.Id == null) { + hasNewRecords = true; + } else if (!hasExistingRecords && delivery.Id != null) { + hasExistingRecords = true; + } + if (hasNewRecords && hasExistingRecords) { + break; + } + } + + if (hasNewRecords) { + validateInsertAccess(); + } + + if (hasExistingRecords) { + validateUpdateAccess(); + } + + List saveResults = Database.upsert( + Security.stripInaccessible(AccessType.UPSERTABLE, serviceDeliveries) + .getRecords(), + allOrNone + ); + return saveResults; + } + + private void validateUpdateAccess() { if ( !PermissionValidator.getInstance() .hasObjectAccess( @@ -79,8 +112,17 @@ public with sharing class ServiceDeliveryDomain { ) { throw new ServiceDeliveryDomainException(Label.UpsertOperationException); } + } - update Security.stripInaccessible(AccessType.UPDATABLE, serviceDeliveries) - .getRecords(); + private void validateInsertAccess() { + if ( + !PermissionValidator.getInstance() + .hasObjectAccess( + ServiceDelivery__c.SObjectType, + PermissionValidator.CRUDAccessType.CREATEABLE + ) + ) { + throw new ServiceDeliveryDomainException(Label.UpsertOperationException); + } } } diff --git a/force-app/main/default/classes/ServiceDeliveryDomain_TEST.cls b/force-app/main/default/classes/ServiceDeliveryDomain_TEST.cls index 0396f6af6..05b449274 100644 --- a/force-app/main/default/classes/ServiceDeliveryDomain_TEST.cls +++ b/force-app/main/default/classes/ServiceDeliveryDomain_TEST.cls @@ -159,6 +159,59 @@ private with sharing class ServiceDeliveryDomain_TEST { } } + @IsTest + private static void shouldReturnUpsertResultsOnUpsertServiceDeliveriesWithAllOrNone() { + TestDataFactory.generateServiceData(); + Service__c service = [SELECT Id FROM Service__c LIMIT 1]; + + List existingServiceDeliveries = [ + SELECT Id, Name + FROM ServiceDelivery__c + ]; + for (ServiceDelivery__c delivery : existingServiceDeliveries) { + System.assertNotEquals('Upserted', delivery.Name); + delivery.Name = 'Upserted'; + delivery.AutonameOverride__c = true; + } + + ServiceDelivery__c newServiceDelivery = new ServiceDelivery__c( + Name = 'Upserted', + AutonameOverride__c = true, + Service__c = service.Id + ); + + List serviceDeliveriesToUpsert = new List(); + serviceDeliveriesToUpsert.addAll(existingServiceDeliveries); + serviceDeliveriesToUpsert.add(newServiceDelivery); + + Test.startTest(); + List results = new ServiceDeliveryDomain() + .upsertServiceDeliveries(serviceDeliveriesToUpsert, false); + Test.stopTest(); + + List serviceDeliveriesAfter = [ + SELECT Id, Name + FROM ServiceDelivery__c + ]; + System.assertEquals( + existingServiceDeliveries.size() + 1, + serviceDeliveriesAfter.size(), + 'One new record should be inserted.' + ); + System.assertEquals( + serviceDeliveriesAfter.size(), + results.size(), + 'Results should be returned for each record upserted.' + ); + for (ServiceDelivery__c delivery : serviceDeliveriesAfter) { + System.assertEquals( + 'Upserted', + delivery.Name, + 'All records should be renamed.' + ); + } + } + @IsTest private static void shouldThrowExceptionWhenInsertPermissionCheckFails() { String methodName = 'hasObjectAccess'; diff --git a/force-app/main/default/classes/ServiceDeliveryService.cls b/force-app/main/default/classes/ServiceDeliveryService.cls index bbde07e14..bcdf87756 100644 --- a/force-app/main/default/classes/ServiceDeliveryService.cls +++ b/force-app/main/default/classes/ServiceDeliveryService.cls @@ -182,12 +182,17 @@ public with sharing class ServiceDeliveryService { ServiceParticipant__c participant ) { SObject particpantRecord = participant; + + Date deliveryDate = session.SessionStart__c == null + ? null + : session.SessionStart__c.date(); + SObject deliveryRecord = new ServiceDelivery__c( ServiceSession__c = session.Id, AttendanceStatus__c = DEFAULT_STATUS, Contact__r = participant.Contact__r, Service_Provider__c = session.PrimaryServiceProvider__c, - DeliveryDate__c = Date.valueOf(session.SessionStart__c), + DeliveryDate__c = deliveryDate, Service__c = session.ServiceSchedule__r.Service__c, Quantity__c = session.ServiceSchedule__r.DefaultServiceQuantity__c ); diff --git a/force-app/main/default/classes/ServiceScheduleCreatorController.cls b/force-app/main/default/classes/ServiceScheduleCreatorController.cls index ae57ce2ee..ce221b6e3 100644 --- a/force-app/main/default/classes/ServiceScheduleCreatorController.cls +++ b/force-app/main/default/classes/ServiceScheduleCreatorController.cls @@ -10,6 +10,8 @@ public with sharing class ServiceScheduleCreatorController { @TestVisible private static ServiceScheduleService service = new ServiceScheduleService(); + @TestVisible + private static ProgramEngagementService peService = new ProgramEngagementService(); @AuraEnabled(cacheable=true) public static ServiceScheduleModel getServiceScheduleModel( @@ -32,6 +34,15 @@ public with sharing class ServiceScheduleCreatorController { } } + @AuraEnabled + public static ProgramEngagement__c getProgramEngagementById(Id peId) { + try { + return peService.getProgramEngagementById(peId); + } catch (Exception ex) { + throw Util.getAuraHandledException(ex); + } + } + @AuraEnabled // Doing fls checks in the validateCreateAccess method in the domain /* sfca-disable-stack ApexFlsViolationRule */ @@ -47,9 +58,31 @@ public with sharing class ServiceScheduleCreatorController { } @AuraEnabled(cacheable=true) - public static SelectParticipantModel getSelectParticipantModel(Id serviceId) { + public static Map getActiveStages() { + try { + return peService.getActiveStagesByValue(); + } catch (Exception ex) { + throw Util.getAuraHandledException(ex); + } + } + + @AuraEnabled(cacheable=true) + public static SelectParticipantModel getSelectParticipantModel( + Id serviceId, + String searchText, + String stage, + String cohortId + ) { + if (cohortId == '') { + cohortId = null; + } try { - return service.getSelectParticipantModel(serviceId); + return service.getSelectParticipantModel( + serviceId, + searchText, + stage, + cohortId + ); } catch (Exception ex) { throw Util.getAuraHandledException(ex); } diff --git a/force-app/main/default/classes/ServiceScheduleCreatorController_TEST.cls b/force-app/main/default/classes/ServiceScheduleCreatorController_TEST.cls index a2a63d30b..c33120e3d 100644 --- a/force-app/main/default/classes/ServiceScheduleCreatorController_TEST.cls +++ b/force-app/main/default/classes/ServiceScheduleCreatorController_TEST.cls @@ -10,6 +10,105 @@ @IsTest public with sharing class ServiceScheduleCreatorController_TEST { private static BasicStub serviceStub = new BasicStub(ServiceScheduleService.class); + private static BasicStub peServiceStub = new BasicStub( + ProgramEngagementService.class + ); + + @IsTest + private static void shouldGetActiveStages() { + String methodName = 'getActiveStagesByValue'; + Map expectedStageMap = new Map{ + 'Active' => 'Active', + 'Enrolled' => 'Enrolled' + }; + + peServiceStub.withReturnValue(methodName, expectedStageMap); + ServiceScheduleCreatorController.peService = (ProgramEngagementService) peServiceStub.createMock(); + + Test.startTest(); + Map actualStageMap = ServiceScheduleCreatorController.getActiveStages(); + Test.stopTest(); + + System.assertEquals( + expectedStageMap, + actualStageMap, + 'Expected stage map was not returned' + ); + peServiceStub.assertCalled(methodName); + } + + @IsTest + private static void shouldGetActiveStagesException() { + String methodName = 'getActiveStagesByValue'; + peServiceStub.withThrowException(methodName); + Exception actualException; + + ServiceScheduleCreatorController.peService = (ProgramEngagementService) peServiceStub.createMock(); + Map actualStageMap; + Test.startTest(); + try { + actualStageMap = ServiceScheduleCreatorController.getActiveStages(); + } catch (Exception ex) { + actualException = ex; + } + Test.stopTest(); + + System.assertEquals( + peServiceStub.testExceptionMessage, + actualException.getMessage(), + 'Expected the controller to rethrow the exception from the service.' + ); + peServiceStub.assertCalled(methodName); + } + + @IsTest + private static void shouldGetProgramEngagementById() { + String methodName = 'getProgramEngagementById'; + Id peId = TestUtil.mockId(ProgramEngagement__c.SObjectType); + ProgramEngagement__c expected = new ProgramEngagement__c(); + expected.Id = peId; + + peServiceStub.withReturnValue(methodName, new List{ Id.class }, expected); + ServiceScheduleCreatorController.peService = (ProgramEngagementService) peServiceStub.createMock(); + + Test.startTest(); + ProgramEngagement__c actual = ServiceScheduleCreatorController.getProgramEngagementById( + peId + ); + Test.stopTest(); + + System.assertEquals( + expected, + actual, + 'Expected Program Engagement was not returned' + ); + peServiceStub.assertCalled(methodName, new List{ Id.class }); + } + + @IsTest + private static void shouldGetProgramEngagementByIdException() { + String methodName = 'getProgramEngagementById'; + peServiceStub.withThrowException(methodName, new List{ Id.class }); + Exception actualException; + Id peId = TestUtil.mockId(ProgramEngagement__c.SObjectType); + ProgramEngagement__c actual; + ServiceScheduleCreatorController.peService = (ProgramEngagementService) peServiceStub.createMock(); + + Test.startTest(); + try { + actual = ServiceScheduleCreatorController.getProgramEngagementById(peId); + } catch (Exception ex) { + actualException = ex; + } + Test.stopTest(); + + System.assertEquals( + peServiceStub.testExceptionMessage, + actualException.getMessage(), + 'Expected the controller to rethrow the exception from the service.' + ); + peServiceStub.assertCalled(methodName, new List{ Id.class }); + } @IsTest private static void shouldGetModelFromService() { @@ -491,14 +590,21 @@ public with sharing class ServiceScheduleCreatorController_TEST { SelectParticipantModel modelToReturn = new SelectParticipantModel(); String methodName = 'getSelectParticipantModel'; - serviceStub.withReturnValue(methodName, Id.class, modelToReturn); + serviceStub.withReturnValue( + methodName, + new List{ Id.class, String.class, String.class, Id.class }, + modelToReturn + ); Test.startTest(); ServiceScheduleCreatorController.service = (ServiceScheduleService) serviceStub.createMock(); SelectParticipantModel actual = ServiceScheduleCreatorController.getSelectParticipantModel( - serviceId + serviceId, + null, + null, + null ); Test.stopTest(); @@ -514,7 +620,102 @@ public with sharing class ServiceScheduleCreatorController_TEST { 'Expected the model returned from the service is what is returned by the controller' ); - serviceStub.assertCalledWith(methodName, Id.class, serviceId); + serviceStub.assertCalledWith( + methodName, + new List{ Id.class, String.class, String.class, Id.class }, + new List{ serviceId, null, null, null } + ); + } + + @IsTest + private static void testGetSelectParticipantModelWithEmptyCohort() { + Id serviceId = TestUtil.mockId(Service__c.SObjectType); + String searchString = 'Test'; + String stageString = ''; + SelectParticipantModel modelToReturn = new SelectParticipantModel(); + String methodName = 'getSelectParticipantModel'; + + serviceStub.withReturnValue( + methodName, + new List{ Id.class, String.class, String.class, Id.class }, + modelToReturn + ); + + Test.startTest(); + + ServiceScheduleCreatorController.service = (ServiceScheduleService) serviceStub.createMock(); + + SelectParticipantModel actual = ServiceScheduleCreatorController.getSelectParticipantModel( + serviceId, + searchString, + stageString, + '' + ); + + Test.stopTest(); + + System.assertNotEquals( + null, + actual, + 'Expected that actual participant model is not null' + ); + System.assertEquals( + true, + modelToReturn === actual, + 'Expected the model returned from the service is what is returned by the controller' + ); + + serviceStub.assertCalledWith( + methodName, + new List{ Id.class, String.class, String.class, Id.class }, + new List{ serviceId, searchString, null, null } + ); + } + + @IsTest + private static void testGetSelectParticipantModelWithSearchAndCohort() { + Id serviceId = TestUtil.mockId(Service__c.SObjectType); + Id cohortId = TestUtil.mockId(ProgramCohort__c.SObjectType); + String searchString = 'Test'; + String stageString = ''; + SelectParticipantModel modelToReturn = new SelectParticipantModel(); + String methodName = 'getSelectParticipantModel'; + + serviceStub.withReturnValue( + methodName, + new List{ Id.class, String.class, String.class, Id.class }, + modelToReturn + ); + + Test.startTest(); + + ServiceScheduleCreatorController.service = (ServiceScheduleService) serviceStub.createMock(); + + SelectParticipantModel actual = ServiceScheduleCreatorController.getSelectParticipantModel( + serviceId, + searchString, + stageString, + cohortId + ); + + Test.stopTest(); + + System.assertNotEquals( + null, + actual, + 'Expected that actual participant model is not null' + ); + System.assertEquals( + true, + modelToReturn === actual, + 'Expected the model returned from the service is what is returned by the controller' + ); + + serviceStub.assertCalledWith( + methodName, + new List{ Id.class, String.class, String.class, Id.class }, + new List{ serviceId, searchString, stageString, cohortId } + ); } @IsTest @@ -524,13 +725,19 @@ public with sharing class ServiceScheduleCreatorController_TEST { SelectParticipantModel actual; Exception actualException; - serviceStub.withThrowException(methodName, Id.class); + serviceStub.withThrowException( + methodName, + new List{ Id.class, String.class, String.class, Id.class } + ); ServiceScheduleCreatorController.service = (ServiceScheduleService) serviceStub.createMock(); Test.startTest(); try { actual = ServiceScheduleCreatorController.getSelectParticipantModel( - serviceId + serviceId, + null, + null, + null ); } catch (Exception e) { actualException = e; @@ -545,7 +752,11 @@ public with sharing class ServiceScheduleCreatorController_TEST { System.assertEquals(null, actual, 'Expected that the actual value is null'); - serviceStub.assertCalledWith(methodName, Id.class, serviceId); + serviceStub.assertCalledWith( + methodName, + new List{ Id.class, String.class, String.class, Id.class }, + new List{ serviceId, null, null, null } + ); } @IsTest diff --git a/force-app/main/default/classes/ServiceScheduleService.cls b/force-app/main/default/classes/ServiceScheduleService.cls index 0c7517f06..214d77628 100644 --- a/force-app/main/default/classes/ServiceScheduleService.cls +++ b/force-app/main/default/classes/ServiceScheduleService.cls @@ -98,9 +98,14 @@ public with sharing class ServiceScheduleService { domain.insertParticipants(engagements, schedule); } - public SelectParticipantModel getSelectParticipantModel(Id serviceId) { + public SelectParticipantModel getSelectParticipantModel( + Id serviceId, + String searchText, + String stage, + Id cohortId + ) { SelectParticipantModel model = new SelectParticipantModel(); - loadAndPopulateParticipantRecords(serviceId, model); + loadAndPopulateParticipantRecords(serviceId, model, searchText, stage, cohortId); return model; } @@ -248,8 +253,16 @@ public with sharing class ServiceScheduleService { private void loadAndPopulateParticipantRecords( Id serviceId, - SelectParticipantModel model + SelectParticipantModel model, + String searchText, + String stage, + Id cohortId ) { + Set stages; + stages = String.isEmpty(stage) + ? progEngagementService.getActiveStages() + : new Set{ stage }; + model.program = programEngagementSelector.getProgramByServiceId(serviceId); if (model.program == null) { return; @@ -262,7 +275,9 @@ public with sharing class ServiceScheduleService { model.programEngagements = programEngagementSelector.getProgramEngagementsByProgramId( model.program.Id, getSelectFieldsWithToLabel(model), - progEngagementService.getActiveStages() + stages, + searchText, + cohortId ); } diff --git a/force-app/main/default/classes/ServiceScheduleService_TEST.cls b/force-app/main/default/classes/ServiceScheduleService_TEST.cls index a12869ed0..4e4d7e112 100644 --- a/force-app/main/default/classes/ServiceScheduleService_TEST.cls +++ b/force-app/main/default/classes/ServiceScheduleService_TEST.cls @@ -906,7 +906,13 @@ public with sharing class ServiceScheduleService_TEST { programEngagementSelectorStub.withReturnValue( programEngagementsMethodName, - new List{ Id.class, Set.class, Set.class }, + new List{ + Id.class, + Set.class, + Set.class, + String.class, + Id.class + }, programEngagementsToReturn ); @@ -920,7 +926,12 @@ public with sharing class ServiceScheduleService_TEST { service.programSelector = (ProgramSelector) programSelectorStub.createMock(); Test.startTest(); - SelectParticipantModel actual = service.getSelectParticipantModel(serviceId); + SelectParticipantModel actual = service.getSelectParticipantModel( + serviceId, + null, + null, + null + ); Test.stopTest(); System.assertNotEquals( @@ -968,11 +979,19 @@ public with sharing class ServiceScheduleService_TEST { programEngagementSelectorStub.assertCalledWith( programEngagementsMethodName, - new List{ Id.class, Set.class, Set.class }, + new List{ + Id.class, + Set.class, + Set.class, + String.class, + Id.class + }, new List{ programId, service.getSelectFieldsWithToLabel(actual), - new Set{ 'Active', 'Enrolled' } + new Set{ 'Active', 'Enrolled' }, + null, + null } ); @@ -983,7 +1002,10 @@ public with sharing class ServiceScheduleService_TEST { private static void shouldReturnNullProgramWhenProgramNotFound() { Test.startTest(); SelectParticipantModel actualModel = service.getSelectParticipantModel( - TestUtil.mockId(Service__c.SObjectType) + TestUtil.mockId(Service__c.SObjectType), + null, + 'Active', + null ); Test.stopTest(); @@ -1051,7 +1073,10 @@ public with sharing class ServiceScheduleService_TEST { // Step 3 SelectParticipantModel participantModel = service.getSelectParticipantModel( - serviceId + serviceId, + null, + null, + null ); System.assert( !participantModel.programEngagements.isEmpty(), diff --git a/force-app/main/default/classes/ServiceSelector.cls b/force-app/main/default/classes/ServiceSelector.cls index 09e15c255..b1fa15381 100755 --- a/force-app/main/default/classes/ServiceSelector.cls +++ b/force-app/main/default/classes/ServiceSelector.cls @@ -16,11 +16,11 @@ public with sharing class ServiceSelector { return new List(); } List queriedServices = [ - SELECT Id, Name, Program__c + SELECT Id, Name, Program__c, Status__c FROM Service__c WHERE Program__c IN :programIds ]; - + queriedServices.sort(); return Security.stripInaccessible(AccessType.READABLE, queriedServices) .getRecords(); } @@ -40,7 +40,7 @@ public with sharing class ServiceSelector { WHERE Id = :programEngagementId ) ]; - + services.sort(); return Security.stripInaccessible(AccessType.READABLE, services).getRecords(); } diff --git a/force-app/main/default/classes/ServiceService.cls b/force-app/main/default/classes/ServiceService.cls index 0f6d4c778..2baf2ea1c 100644 --- a/force-app/main/default/classes/ServiceService.cls +++ b/force-app/main/default/classes/ServiceService.cls @@ -11,36 +11,118 @@ public with sharing class ServiceService { public ServiceService() { } + private static final String SERVICE_STATUS_ACTIVE = 'ServiceStatusActive'; + private static final String ENGAGEMENTS = 'engagements'; + private static final String SERVICES = 'services'; + private static final String LABEL = 'label'; + private static final String VALUE = 'value'; + private static final String NAME = 'Name'; + private static final String Id = 'Id'; + private static final String PROGRAM = 'program'; + private static final String BSDT_ACTIVE_FILTER = 'BSDTActiveFilter'; + + @TestVisible + private FieldBucketSelector bucketSelector = new FieldBucketSelector(); + @TestVisible private FieldSetService fieldSetService = new FieldSetService(); @TestVisible private ServiceSelector serviceSelector = new ServiceSelector(); + @TestVisible + private ProgramEngagementService engagementService = new ProgramEngagementService(); + @TestVisible private ProgramEngagementSelector engagementSelector = new ProgramEngagementSelector(); + @TestVisible + private Set activeStatuses { + get { + if (activeStatuses == null) { + activeStatuses = getActiveStatuses(); + } + return activeStatuses; + } + set; + } + + private Set getActiveStatuses() { + List bucketNames = new List{ SERVICE_STATUS_ACTIVE }; + Set activeStatuses = new Set(); + + Schema.SObjectType serviceSObjType = Service__c.SObjectType; + Schema.SObjectField statusField = Service__c.Status__c; + + for ( + Bucket__mdt bucket : bucketSelector.getBuckets( + bucketNames, + serviceSObjType, + statusField + ) + ) { + for (BucketedValue__mdt value : bucket.BucketedValues__r) { + activeStatuses.add(value.Value__c); + } + } + + return activeStatuses; + } + + private Boolean isBsdtActiveFilterActive() { + List features = CustomMetadataSelector.getInstance() + .getAllFeatureGates(); + + for (FeatureGate__mdt feature : features) { + if ( + feature.IsActive__c && BSDT_ACTIVE_FILTER.contains(feature.DeveloperName) + ) { + return feature.IsActive__c; + } + } + + return false; + } + public Map> getServicesEngagementsByContactId(Id contactId) { Set programIds = new Set(); String serviceProgram = Schema.SObjectType.Service__c.Fields.Program__c.getName(); String engageProgram = Schema.SObjectType.ProgramEngagement__c.Fields.Program__c.getName(); + Set activeServiceStatuses = new Set(); + Set activeProgramEngagementStages = new Set(); + Boolean bsdtFilterIsActive = isBsdtActiveFilterActive(); + + if (bsdtFilterIsActive) { + activeServiceStatuses = activeStatuses; + activeProgramEngagementStages = engagementService.getActiveStages(); + } Map> result = new Map>(); - result.put('engagements', new List()); - result.put('services', new List()); + result.put(ENGAGEMENTS, new List()); + result.put(SERVICES, new List()); for ( ProgramEngagement__c engagement : engagementSelector.getProgramEngagementsByContactId( contactId ) ) { - programIds.add(engagement.Program__c); - result.get('engagements') - .add(convertObjectToOption(engagement, engageProgram)); + if ( + activeProgramEngagementStages.isEmpty() || + activeProgramEngagementStages.contains(engagement.Stage__c) + ) { + programIds.add(engagement.Program__c); + result.get(ENGAGEMENTS) + .add(convertObjectToOption(engagement, engageProgram)); + } } for (Service__c service : serviceSelector.getServicesByProgramIds(programIds)) { - result.get('services').add(convertObjectToOption(service, serviceProgram)); + if ( + activeServiceStatuses.isEmpty() || + activeServiceStatuses.contains(service.Status__c) + ) { + result.get(SERVICES).add(convertObjectToOption(service, serviceProgram)); + } } return result; @@ -48,9 +130,9 @@ public with sharing class ServiceService { private Map convertObjectToOption(sObject obj, String programField) { Map result = new Map(); - result.put('label', (String) obj.get('Name')); - result.put('value', (String) obj.get('Id')); - result.put('program', (String) obj.get(programField)); + result.put(LABEL, (String) obj.get(NAME)); + result.put(VALUE, (String) obj.get(ID)); + result.put(PROGRAM, (String) obj.get(programField)); return result; } diff --git a/force-app/main/default/classes/ServiceService_TEST.cls b/force-app/main/default/classes/ServiceService_TEST.cls index 51aad9973..05ceb8e23 100644 --- a/force-app/main/default/classes/ServiceService_TEST.cls +++ b/force-app/main/default/classes/ServiceService_TEST.cls @@ -10,7 +10,7 @@ @isTest public with sharing class ServiceService_TEST { @IsTest - private static void testGetServicesEngagementsByContactId() { + private static void testGetServicesEngagementsByContactIdFeatureOn() { Id contactId = TestUtil.mockId(Contact.SObjectType); Program__c program1 = new Program__c( @@ -23,6 +23,14 @@ public with sharing class ServiceService_TEST { ProgramEngagement__c engagement1 = new ProgramEngagement__c( Name = 'Engagement 1', + Stage__c = 'Active', + Contact__c = contactId, + Program__c = program1.Id, + Role__c = 'Client' + ); + + ProgramEngagement__c engagement2 = new ProgramEngagement__c( + Name = 'Engagement 2', Stage__c = 'Enrolled', Contact__c = contactId, Program__c = program1.Id, @@ -37,10 +45,155 @@ public with sharing class ServiceService_TEST { UnitOfMeasurement__c = 'Hours' ); + Service__c service2 = new Service__c( + Id = TestUtil.mockId(Service__c.SObjectType), + Name = 'Service 2', + Program__c = program1.Id, + Status__c = 'Planned', + UnitOfMeasurement__c = 'Hours' + ); + List engagements = new List{ - engagement1 + engagement1, + engagement2 }; - List services = new List{ service1 }; + List services = new List{ service1, service2 }; + List bucketNames = new List{ 'ServiceStatusActive' }; + Schema.SObjectType serviceSObjType = Service__c.SObjectType; + Schema.SObjectField statusField = Service__c.Status__c; + List statusBuckets = createBuckets(); + Set activeStages = new Set{ 'Active' }; + + TestStub engagementServiceStub = new StubBuilder(ProgramEngagementService.class) + .when('getActiveStages') + .called() + .thenReturn(activeStages) + .build(); + TestStub engagementSelectorStub = new StubBuilder(ProgramEngagementSelector.class) + .when('getProgramEngagementsByContactId', Id.class) + .calledWith(contactId) + .thenReturn(engagements) + .build(); + TestStub serviceSelectorStub = new StubBuilder(ServiceSelector.class) + .when('getServicesByProgramIds', Set.class) + .calledWith(new Set{ program1.Id }) + .thenReturn(services) + .build(); + TestStub bucketSelectorStub = new StubBuilder(FieldBucketSelector.class) + .when( + 'getBuckets', + List.class, + Schema.SObjectType.class, + Schema.SObjectField.class + ) + .calledWith(bucketNames, serviceSObjType, statusField) + .thenReturn(statusBuckets) + .build(); + + ServiceService service = new ServiceService(); + service.serviceSelector = (ServiceSelector) serviceSelectorStub.create(); + service.engagementSelector = (ProgramEngagementSelector) engagementSelectorStub.create(); + service.bucketSelector = (FieldBucketSelector) bucketSelectorStub.create(); + service.engagementService = (ProgramEngagementService) engagementServiceStub.create(); + + // By turning off none, we turn on all + TestUtil.turnOffFeatureGates(new Set{}); + + Test.startTest(); + Map> actual = service.getServicesEngagementsByContactId( + contactId + ); + Test.stopTest(); + + Set expectedKeySet = new Set{ 'engagements', 'services' }; + System.assertEquals( + expectedKeySet, + actual.keySet(), + 'Expected both keys to be returned.' + ); + + for (List objList : actual.values()) { + System.assert(!objList.isEmpty()); + } + + System.assertEquals( + 1, + actual.get('engagements').size(), + 'Only active Program Engagements should be returned' + ); + + System.assertEquals( + 1, + actual.get('services').size(), + 'Only active Services should be returned' + ); + + engagementSelectorStub.assertCalledAsExpected(); + serviceSelectorStub.assertCalledAsExpected(); + bucketSelectorStub.assertCalledAsExpected(); + } + + @IsTest + private static void testGetServicesEngagementsByContactIdFeatureOff() { + final String BSDT_ACTIVE_FILTER = 'BSDTActiveFilter'; + Id contactId = TestUtil.mockId(Contact.SObjectType); + + Program__c program1 = new Program__c( + Id = TestUtil.mockId(Program__c.SObjectType), + Name = 'Program 1', + Status__c = 'Active', + StartDate__c = Date.today(), + EndDate__c = Date.today().addDays(30) + ); + + ProgramEngagement__c engagement1 = new ProgramEngagement__c( + Name = 'Engagement 1', + Stage__c = 'Active', + Contact__c = contactId, + Program__c = program1.Id, + Role__c = 'Client' + ); + + ProgramEngagement__c engagement2 = new ProgramEngagement__c( + Name = 'Engagement 2', + Stage__c = 'Enrolled', + Contact__c = contactId, + Program__c = program1.Id, + Role__c = 'Client' + ); + + Service__c service1 = new Service__c( + Id = TestUtil.mockId(Service__c.SObjectType), + Name = 'Service 1', + Program__c = program1.Id, + Status__c = 'Active', + UnitOfMeasurement__c = 'Hours' + ); + + Service__c service2 = new Service__c( + Id = TestUtil.mockId(Service__c.SObjectType), + Name = 'Service 2', + Program__c = program1.Id, + Status__c = 'Planned', + UnitOfMeasurement__c = 'Hours' + ); + + List engagements = new List{ + engagement1, + engagement2 + }; + List services = new List{ service1, service2 }; + List bucketNames = new List{ 'ServiceStatusActive' }; + Schema.SObjectType serviceSObjType = Service__c.SObjectType; + Schema.SObjectField statusField = Service__c.Status__c; + List statusBuckets = createBuckets(); + Set activeStages = new Set{ 'Active' }; + + TestStub engagementServiceStub = new StubBuilder(ProgramEngagementService.class) + .when('getActiveStages') + .called() + .thenReturn(activeStages) + .build(); TestStub engagementSelectorStub = new StubBuilder(ProgramEngagementSelector.class) .when('getProgramEngagementsByContactId', Id.class) .calledWith(contactId) @@ -51,10 +204,24 @@ public with sharing class ServiceService_TEST { .calledWith(new Set{ program1.Id }) .thenReturn(services) .build(); + TestStub bucketSelectorStub = new StubBuilder(FieldBucketSelector.class) + .when( + 'getBuckets', + List.class, + Schema.SObjectType.class, + Schema.SObjectField.class + ) + .calledWith(bucketNames, serviceSObjType, statusField) + .thenReturn(statusBuckets) + .build(); ServiceService service = new ServiceService(); service.serviceSelector = (ServiceSelector) serviceSelectorStub.create(); service.engagementSelector = (ProgramEngagementSelector) engagementSelectorStub.create(); + service.bucketSelector = (FieldBucketSelector) bucketSelectorStub.create(); + service.engagementService = (ProgramEngagementService) engagementServiceStub.create(); + + TestUtil.turnOffFeatureGates(new Set{ BSDT_ACTIVE_FILTER }); Test.startTest(); Map> actual = service.getServicesEngagementsByContactId( @@ -73,6 +240,18 @@ public with sharing class ServiceService_TEST { System.assert(!objList.isEmpty()); } + System.assertEquals( + 2, + actual.get('engagements').size(), + 'All Program Engagements should be returned' + ); + + System.assertEquals( + 2, + actual.get('services').size(), + 'All Services should be returned' + ); + engagementSelectorStub.assertCalledAsExpected(); serviceSelectorStub.assertCalledAsExpected(); } @@ -701,4 +880,35 @@ public with sharing class ServiceService_TEST { fieldSetServiceStub.assertCalledAsExpected(); } + + private static List createBuckets() { + BucketedField__mdt bucketedField = (BucketedField__mdt) new TestUtil.BucketedFieldBuilder() + .withDeveloperName('ServiceStatuses') + .withQualifiedApiName(Util.prefixNamespace('ServiceStatuses')) + .withField('Status__c') + .withObject('Service__c') + .withMockId() + .build(); + + List activeValues = new List(); + activeValues.add( + (BucketedValue__mdt) new TestUtil.BucketedValueBuilder() + .withDeveloperName('ServiceStatusActive') + .withQualifiedApiName(Util.prefixNamespace('ServiceStatusActive')) + .withBucket('ServiceStatusActive') + .withValue('Active') + .withMockId() + .build() + ); + + Bucket__mdt activeBucket = (Bucket__mdt) new TestUtil.BucketBuilder() + .withBucketedField(bucketedField) + .withBucketedValues(activeValues) + .withDeveloperName('ServiceStatusActive') + .withQualifiedApiName(Util.prefixNamespace('ServiceStatusActive')) + .withMockId() + .build(); + + return new List{ activeBucket }; + } } diff --git a/force-app/main/default/classes/TestUtil.cls b/force-app/main/default/classes/TestUtil.cls index ad1ab1073..aff65f9aa 100755 --- a/force-app/main/default/classes/TestUtil.cls +++ b/force-app/main/default/classes/TestUtil.cls @@ -13,6 +13,7 @@ */ public with sharing class TestUtil { private static final Set featureGateNames = new Set{ + 'BSDTActiveFilter', 'ServiceDeliveriesToContact', 'ServiceDeliveriesToService', 'ServiceDeliveriesToServiceSession', diff --git a/force-app/main/default/customMetadata/Bucket.ServiceStatusActive.md-meta.xml b/force-app/main/default/customMetadata/Bucket.ServiceStatusActive.md-meta.xml new file mode 100644 index 000000000..361af2506 --- /dev/null +++ b/force-app/main/default/customMetadata/Bucket.ServiceStatusActive.md-meta.xml @@ -0,0 +1,9 @@ + + + + false + + BucketedField__c + ServiceStatuses + + diff --git a/force-app/main/default/customMetadata/BucketedField.ServiceStatuses.md-meta.xml b/force-app/main/default/customMetadata/BucketedField.ServiceStatuses.md-meta.xml new file mode 100644 index 000000000..030117da0 --- /dev/null +++ b/force-app/main/default/customMetadata/BucketedField.ServiceStatuses.md-meta.xml @@ -0,0 +1,13 @@ + + + + false + + Field__c + Status__c + + + Object__c + Service__c + + diff --git a/force-app/main/default/customMetadata/BucketedValue.ServiceStatusActive.md-meta.xml b/force-app/main/default/customMetadata/BucketedValue.ServiceStatusActive.md-meta.xml new file mode 100644 index 000000000..9b409ac87 --- /dev/null +++ b/force-app/main/default/customMetadata/BucketedValue.ServiceStatusActive.md-meta.xml @@ -0,0 +1,13 @@ + + + + false + + Bucket__c + ServiceStatusActive + + + Value__c + Active + + diff --git a/force-app/main/default/customMetadata/FeatureGate.BSDTActiveFilter.md-meta.xml b/force-app/main/default/customMetadata/FeatureGate.BSDTActiveFilter.md-meta.xml new file mode 100644 index 000000000..79917bd92 --- /dev/null +++ b/force-app/main/default/customMetadata/FeatureGate.BSDTActiveFilter.md-meta.xml @@ -0,0 +1,9 @@ + + + + false + + IsActive__c + false + + diff --git a/force-app/main/default/labels/CustomLabels.labels-meta.xml b/force-app/main/default/labels/CustomLabels.labels-meta.xml index 06be9fca2..b86144dc8 100644 --- a/force-app/main/default/labels/CustomLabels.labels-meta.xml +++ b/force-app/main/default/labels/CustomLabels.labels-meta.xml @@ -413,8 +413,15 @@ Filter_by_Record en_US true - Filter by {SObject Label} - Filter by {0} + Filter by: {SObject Label} + Filter by: {0} + + + Filter_by_Stage + en_US + true + Filter by Stage + Filter by Stage Finish @@ -1096,4 +1103,18 @@ This feature is only available on the web application This feature is only available on the web application. + + Too_Many_Participants + en_US + true + Too many results returned from participant selector + The results have returned more than a 1000 participants. Refine the results by searching or filtering by Program Cohort. + + + Service_Schedule_Wizard + en_US + true + Service Schedule Wizard + Service Schedule Wizard + diff --git a/force-app/main/default/lwc/__tests__/lightning/modal/modal.html b/force-app/main/default/lwc/__tests__/lightning/modal/modal.html new file mode 100644 index 000000000..b9ee2f3f4 --- /dev/null +++ b/force-app/main/default/lwc/__tests__/lightning/modal/modal.html @@ -0,0 +1,5 @@ + diff --git a/force-app/main/default/lwc/__tests__/lightning/modal/modal.js b/force-app/main/default/lwc/__tests__/lightning/modal/modal.js new file mode 100644 index 000000000..7d8bf68c2 --- /dev/null +++ b/force-app/main/default/lwc/__tests__/lightning/modal/modal.js @@ -0,0 +1,7 @@ +import { LightningElement, api } from "lwc"; + +export default class Modal extends LightningElement { + @api content; + @api open() {} + @api close() {} +} diff --git a/force-app/main/default/lwc/__tests__/lightning/modalBody/modalBody.html b/force-app/main/default/lwc/__tests__/lightning/modalBody/modalBody.html new file mode 100644 index 000000000..53c4b8d5c --- /dev/null +++ b/force-app/main/default/lwc/__tests__/lightning/modalBody/modalBody.html @@ -0,0 +1,4 @@ + diff --git a/force-app/main/default/lwc/__tests__/lightning/modalBody/modalBody.js b/force-app/main/default/lwc/__tests__/lightning/modalBody/modalBody.js new file mode 100644 index 000000000..a8a972680 --- /dev/null +++ b/force-app/main/default/lwc/__tests__/lightning/modalBody/modalBody.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from "lwc"; + +export default class ModalBody extends LightningElement { + @api content; +} diff --git a/force-app/main/default/lwc/__tests__/lightning/modalFooter/modalFooter.html b/force-app/main/default/lwc/__tests__/lightning/modalFooter/modalFooter.html new file mode 100644 index 000000000..02ece5b64 --- /dev/null +++ b/force-app/main/default/lwc/__tests__/lightning/modalFooter/modalFooter.html @@ -0,0 +1,5 @@ + diff --git a/force-app/main/default/lwc/__tests__/lightning/modalFooter/modalFooter.js b/force-app/main/default/lwc/__tests__/lightning/modalFooter/modalFooter.js new file mode 100644 index 000000000..31663a7ba --- /dev/null +++ b/force-app/main/default/lwc/__tests__/lightning/modalFooter/modalFooter.js @@ -0,0 +1,3 @@ +import { LightningElement } from "lwc"; + +export default class ModalFooter extends LightningElement {} diff --git a/force-app/main/default/lwc/__tests__/lightning/modalHeader/modalHeader.html b/force-app/main/default/lwc/__tests__/lightning/modalHeader/modalHeader.html new file mode 100644 index 000000000..fcaa99007 --- /dev/null +++ b/force-app/main/default/lwc/__tests__/lightning/modalHeader/modalHeader.html @@ -0,0 +1,8 @@ + diff --git a/force-app/main/default/lwc/__tests__/lightning/modalHeader/modalHeader.js b/force-app/main/default/lwc/__tests__/lightning/modalHeader/modalHeader.js new file mode 100644 index 000000000..9f99ebced --- /dev/null +++ b/force-app/main/default/lwc/__tests__/lightning/modalHeader/modalHeader.js @@ -0,0 +1,5 @@ +import { LightningElement, api } from "lwc"; + +export default class ModalHeader extends LightningElement { + @api label; +} diff --git a/force-app/main/default/lwc/bulkServiceDeliveryUI/bulkServiceDeliveryUI.html b/force-app/main/default/lwc/bulkServiceDeliveryUI/bulkServiceDeliveryUI.html index 75ac29938..3ecdc5a67 100644 --- a/force-app/main/default/lwc/bulkServiceDeliveryUI/bulkServiceDeliveryUI.html +++ b/force-app/main/default/lwc/bulkServiceDeliveryUI/bulkServiceDeliveryUI.html @@ -34,9 +34,7 @@ default-values={delivery} service-delivery-field-sets={serviceDeliveryFieldSets} index={delivery.index} - onsuccess={handleRowSuccess} ondelete={handleRowDelete} - onerror={handleRowError} row-count={rowCount} should-focus={delivery.shouldFocus} > diff --git a/force-app/main/default/lwc/bulkServiceDeliveryUI/bulkServiceDeliveryUI.js b/force-app/main/default/lwc/bulkServiceDeliveryUI/bulkServiceDeliveryUI.js index 1a3e60eec..0ee4b4901 100644 --- a/force-app/main/default/lwc/bulkServiceDeliveryUI/bulkServiceDeliveryUI.js +++ b/force-app/main/default/lwc/bulkServiceDeliveryUI/bulkServiceDeliveryUI.js @@ -39,6 +39,7 @@ import SERVICE_FIELD from "@salesforce/schema/ServiceDelivery__c.Service__c"; import SERVICEDELIVERY_OBJECT from "@salesforce/schema/ServiceDelivery__c"; import getFieldSets from "@salesforce/apex/ServiceDeliveryController.getServiceDeliveryFieldSets"; +import upsertRows from "@salesforce/apex/ServiceDeliveryController.upsertServiceDeliveries"; import pmmFolder from "@salesforce/resourceUrl/pmm"; export default class BulkServiceDeliveryUI extends NavigationMixin(LightningElement) { @@ -103,7 +104,9 @@ export default class BulkServiceDeliveryUI extends NavigationMixin(LightningElem set defaultValues(value) { let serviceDelivery = this.serviceDeliveries[0]; this._defaultValues = value; - Object.assign(serviceDelivery, this._defaultValues); + if (serviceDelivery) { + Object.assign(serviceDelivery, this._defaultValues); + } } @api @@ -178,6 +181,10 @@ export default class BulkServiceDeliveryUI extends NavigationMixin(LightningElem return "error"; } + get isSaveDisabled() { + return this.isSaving; + } + addDelivery() { let serviceDelivery = { index: this._nextIndex, @@ -202,13 +209,6 @@ export default class BulkServiceDeliveryUI extends NavigationMixin(LightningElem } } - savingComplete() { - if (this.currentSaveCount - this.savedCount - this.errorCount === 0) { - return true; - } - return false; - } - showSaveSummaryToast() { let toastVariant = this.savingCompleteToastVariant; let toastTitle = toastVariant === "success" ? this.labels.success : ""; @@ -216,48 +216,94 @@ export default class BulkServiceDeliveryUI extends NavigationMixin(LightningElem showToast(toastTitle, this.savingCompleteMessage, toastVariant, "dismissible"); } - // eslint-disable-next-line no-unused-vars - handleRowError(event) { - this.errorCount++; - - if (this.savingComplete()) { - this.showSaveSummaryToast(); - } - } - handleSave() { let rows = this.template.querySelectorAll("c-service-delivery-row"); + let deliveries = []; this.savedCount = 0; this.errorCount = 0; this.targetSaveCount = 0; - this.currentSaveCount = 0; rows.forEach(row => { if (row.isDirty || row.isError) { this.targetSaveCount++; } + if (row.isDirty) { - this.currentSaveCount++; + let delivery = row.row; + delivery.index = row.index; + if (!delivery.isError) { + deliveries.push(delivery); + row.setSaving(); + } else { + this.errorCount++; + } } - row.saveRow(); }); if (this.targetSaveCount === 0) { this.dispatchEvent(new CustomEvent("done")); + return; + } + + this.upsertDeliveries(deliveries); + } + + upsertDeliveries(deliveries) { + if (deliveries.length === 0) { + return; } + + this.isSaving = true; + upsertRows({ + serviceDeliveries: deliveries, + allOrNone: false, + }) + .then(results => { + let resultByIndex = this.processResults(results, deliveries); + this.updateRows(resultByIndex); + }) + .catch(error => { + handleError(error); + }) + .finally(() => { + this.isSaving = false; + this.showSaveSummaryToast(); + this.dispatchEvent(new CustomEvent("done")); + }); } - // eslint-disable-next-line no-unused-vars - handleRowSuccess(event) { - this.savedCount++; + processResults(results, deliveries) { + let resultByIndex = {}; + results = JSON.parse(results); - if (this.savingComplete()) { - this.showSaveSummaryToast(); + for (let i = 0; i < deliveries.length; i++) { + deliveries[i].id = results[i].id; + deliveries[i].result = results[i]; + resultByIndex[deliveries[i].index] = deliveries[i]; } + return resultByIndex; + } - if (this.savedCount === this.targetSaveCount) { - this.dispatchEvent(new CustomEvent("done")); + updateRows(resultByIndex) { + let rows = this.template.querySelectorAll("c-service-delivery-row"); + if (rows) { + rows.forEach(row => { + if ( + row.isDirty && + Object.prototype.hasOwnProperty.call(resultByIndex, row.index) + ) { + let delivery = resultByIndex[row.index]; + + if (delivery.result.success) { + this.savedCount++; + row.handleSuccess(delivery); + } else { + this.errorCount++; + row.handleSaveErrors(delivery.result.errors); + } + } + }); } } diff --git a/force-app/main/default/lwc/participantSelector/participantSelector.html b/force-app/main/default/lwc/participantSelector/participantSelector.html index 2fb913c4c..b3e939ea0 100644 --- a/force-app/main/default/lwc/participantSelector/participantSelector.html +++ b/force-app/main/default/lwc/participantSelector/participantSelector.html @@ -28,7 +28,10 @@ class="slds-var-p-right_small slds-var-p-top_xx-small" > - + - + + + + @@ -78,8 +89,14 @@ + + +
-
-
- -
- {labels.noRecordsFound} +
+ {labels.noRecordsFound} +
2) { + return 5; + } + return 6; + } + + get showStageInput() { + if (this.stageOptions && this.stageOptions.length > 2) { + return true; + } + return false; + } + get showCapacityWarning() { return ( this.capacity !== undefined && @@ -161,7 +184,19 @@ export default class ParticipantSelector extends LightningElement { return `${name} (${this.participantCount}/${this.capacity})`; } - @wire(getSelectParticipantModel, { serviceId: "$serviceId" }) + @wire(getActiveStages, {}) + setupStages(result) { + if (result.data) { + this.loadStageOptions(result.data); + } + } + + @wire(getSelectParticipantModel, { + serviceId: "$serviceId", + searchText: "$wiredSearchValue", + stage: "$selectedStage", + cohortId: "$cohortId", + }) dataSetup(result) { this.wiredData = result; if (!(result.data || result.error)) { @@ -203,36 +238,43 @@ export default class ParticipantSelector extends LightningElement { this.labels.filterByCohort = format(filterByRecord, [ this.objectLabels.programCohort.objectLabel, ]); + this.labels.filterByStage = format(filterByRecord, [ + this.fields.engagementStage.label, + ]); } loadTableRows(data) { let selectedIds = this.selectedEngagements.map(engagement => engagement.Id); this.allEngagements = data.programEngagements.slice(0); + this.show1kMessage = this.allEngagements.length >= 1000 ? true : false; this.availableEngagementRows = this.allEngagements .filter(engagement => !selectedIds.includes(engagement.Id)) .map(engagement => { // Flatten relationship fields let programEngagement = { ...engagement }; - for (const [field, value] of Object.entries(programEngagement)) { - let isTimeField = - this.fieldByFieldPath[field] && - this.fieldByFieldPath[field].type === TIME; - if (isTimeField) { - programEngagement[field] = formatTime(value); - } - if (typeof value === "object") { - for (const [parentField, parentValue] of Object.entries(value)) { - programEngagement[field + parentField] = parentValue; - } - } - } - - return programEngagement; + return this.flattenProgramEngagement(programEngagement); }); this.sortData(this.availableEngagementRows); } + flattenProgramEngagement(programEngagement) { + for (const [field, value] of Object.entries(programEngagement)) { + let isTimeField = + this.fieldByFieldPath[field] && + this.fieldByFieldPath[field].type === TIME; + if (isTimeField) { + programEngagement[field] = formatTime(value); + } + if (typeof value === "object") { + for (const [parentField, parentValue] of Object.entries(value)) { + programEngagement[field + parentField] = parentValue; + } + } + } + return programEngagement; + } + dispatchLoaded() { if (this.isLoaded) { return; @@ -255,9 +297,26 @@ export default class ParticipantSelector extends LightningElement { this.processNewParticipant(event.detail); } - async processNewParticipant(id) { + async processNewParticipant(peId) { await refreshApex(this.wiredData); - this.handleSelectById(id); + let row = this.availableEngagementRows.find(element => element.Id === peId); + if (row) { + this.handleSelectById(peId); + } else { + this.loadNewProgramEngagement(peId); + } + } + + loadNewProgramEngagement(peId) { + getProgramEngagementById({ peId }) + .then(result => { + let thisPE = this.flattenProgramEngagement(result); + this.availableEngagementRows.push(thisPE); + this.handleSelectById(peId); + }) + .catch(error => { + handleError(error); + }); } handleLoadMore() { @@ -301,6 +360,16 @@ export default class ParticipantSelector extends LightningElement { this.handleSelectParticipants(); } + loadStageOptions(data) { + this.stageOptions = Object.entries(data).map(([key, value]) => { + let newObj = {}; + newObj.label = value; + newObj.value = key; + return newObj; + }); + this.stageOptions.unshift({ label: this.labels.none, value: "" }); + } + loadProgramCohorts(data) { this.cohorts = data.programCohorts.slice(0); this.searchOptions = this.cohorts.map(element => { @@ -362,10 +431,18 @@ export default class ParticipantSelector extends LightningElement { }); } - handleCohortChange(event) { - this.cohortId = event.detail.value; + handleStageChange(event) { + if (this.selectedStage !== event.detail.value) { + this.displaySpinner(); + this.selectedStage = event.detail.value; + } + } - this.applyFilters(); + handleCohortChange(event) { + if (this.cohortId !== event.detail.value) { + this.displaySpinner(); + this.cohortId = event.detail.value; + } } handleSelectAll() { @@ -430,10 +507,13 @@ export default class ParticipantSelector extends LightningElement { this.selectedEngagements = tempSelectedEngagements; - this.availableEngagementRows = [ - ...this.availableEngagementRows, - event.detail.row, - ]; + //Verify the deselected row is in the current dataset before displaying + if (this.allEngagements.some(row => row.Id === event.detail.row.Id)) { + this.availableEngagementRows = [ + ...this.availableEngagementRows, + event.detail.row, + ]; + } this.sortData(this.availableEngagementRows); //filter previouslySelectedEngagements to remove program engagement that we deselected so it does not add the deselected value back @@ -451,8 +531,15 @@ export default class ParticipantSelector extends LightningElement { } handleInputChange(event) { - this.searchValue = event.target.value; - this.debounceSearch(); + if (event.target && event.target.value) { + this.searchValue = event.target.value; + if (this.searchValue.length !== 1) { + this.debounceSearch(); + } + } else { + this.searchValue = ""; + this.debounceSearch(); + } } debounceSearch() { @@ -470,27 +557,19 @@ export default class ParticipantSelector extends LightningElement { startFilter() { this.displaySpinner(); // eslint-disable-next-line @lwc/lwc/no-async-operation - setTimeout(this.applyFilters.bind(this)); + setTimeout(this.updateSearchValue.bind(this)); } - applyFilters() { - let searchText = this.searchValue ? this.searchValue.toLowerCase() : ""; - - this.filteredEngagements = this.availableEngagementRows.filter( - row => - JSON.stringify(row) - .toLowerCase() - .includes(searchText) && - (this.cohortId - ? row[this.fields.programCohort.apiName] === this.cohortId - : true) - ); + updateSearchValue() { + this.wiredSearchValue = this.searchValue; + } + applyFilters() { + this.filteredEngagements = this.availableEngagementRows; this.availableEngagementsForSelection = this.filteredEngagements.slice( 0, Math.min(this.filteredEngagements.length, this.offset) ); - this.hideSpinner(); } diff --git a/force-app/main/default/lwc/serviceDeliveryRow/serviceDeliveryRow.html b/force-app/main/default/lwc/serviceDeliveryRow/serviceDeliveryRow.html index 72177e063..aff61ba3c 100644 --- a/force-app/main/default/lwc/serviceDeliveryRow/serviceDeliveryRow.html +++ b/force-app/main/default/lwc/serviceDeliveryRow/serviceDeliveryRow.html @@ -11,9 +11,6 @@
@@ -43,6 +40,7 @@ 0) { + comboboxes.forEach(combobox => { + row[combobox.name] = combobox.value; + }); + } + + let inputFields = this.template.querySelectorAll("lightning-input-field"); + if (inputFields && inputFields.length > 0) { + inputFields.forEach(field => { + row[field.fieldName] = field.value; + }); } - let deliverySubmit = this.template.querySelector(".sd-submit"); - if (deliverySubmit) { - deliverySubmit.click(); + + if (this.programEngagementId) { + row[PROGRAMENGAGEMENT_FIELD.fieldApiName] = this.programEngagementId; + } + + if (this.serviceId) { + row[SERVICE_FIELD.fieldApiName] = this.serviceId; } + + row.isError = this.reportValidity(); + return row; + } + + reportValidity() { + if (this.hasProgramEngagementField && !this.programEngagementId) { + this.isError = true; + this.errorMessage = handleError( + this.labels.selectEngagement, + false, + "dismissible", + true + ); + } + + let comboboxes = this.template.querySelectorAll("lightning-combobox"); + if (comboboxes?.length > 0) { + comboboxes.forEach(combobox => { + if (!combobox.reportValidity()) { + this.isError = true; + } + }); + } + + let inputFields = this.template.querySelectorAll("lightning-input-field"); + if (inputFields?.length > 0) { + inputFields.forEach(field => { + if (!field.reportValidity()) { + this.isError = true; + } + }); + } + + return this.isError; } get isDeleteDisabled() { @@ -273,62 +330,63 @@ export default class ServiceDeliveryRow extends LightningElement { this.setDisabledAttribute(); } - handleSaveError(event) { - if (!this.isError) { - if ( - JSON.stringify(event.detail).includes("UNABLE_TO_LOCK_ROW") && - this.errorRetryCount < this.errorRetryMax - ) { - this.errorRetryCount++; - this.saveRow(); - return; - } + @api + handleSaveErrors(errors) { + if (!errors?.length || this.isError) { + return; + } - this.errorMessage = handleError(event, false, "dismissible", true); - this.errorRetryCount = 0; - this.isDirty = false; - this.isSaving = false; - this.isSaved = false; - this.isError = true; + this.errorByField = new Map(); + errors.forEach(e => { + if (e.fields?.length > 0) { + e.fields.forEach(field => { + this.errorByField.set(field, e.message); + }); + } + }); - event.detail.index = this.index; - this.dispatchEvent(new CustomEvent("error", { detail: event.detail })); + this.errorMessage = handleError(errors, false, "dismissible", false); + this.isDirty = false; + this.isSaving = false; + this.isSaved = false; + this.isError = true; + this.setCustomValidity(); + } + + setCustomValidity() { + if (this.errorByField?.size > 0) { + this.errorByField.keys().forEach(fieldName => { + const input = this.getFieldInput(fieldName); + if (input && typeof input.setErrors === "function") { + const outputErrors = { + body: { + output: { + fieldErrors: {}, + }, + }, + }; + outputErrors.body.output.fieldErrors[fieldName] = [ + { message: this.errorByField.get(fieldName) }, + ]; + input.setErrors(outputErrors); + } else if (input && typeof input.setCustomValidity === "function") { + input.setCustomValidity(this.errorByField.get(fieldName)); + input.reportValidity(); + } + }); } } - handleSuccess(event) { - this.recordId = event.detail.id; - this.setSaved(); - this.setDisabledAttribute(); - fireEvent(this.pageRef, "serviceDeliveryUpsert", event.detail); + getFieldInput(fieldName) { + return this.template.querySelector(`[data-name=${fieldName}]`); } - handleSubmit(event) { - let fields = event.detail.fields; - - if (this.hasProgramEngagementField && !this.programEngagementId) { - this.isError = true; - this.errorMessage = handleError( - this.labels.selectEngagement, - false, - "dismissible", - true - ); - } - - if (!this.isError) { - if (this.programEngagementId) { - fields[PROGRAMENGAGEMENT_FIELD.fieldApiName] = this.programEngagementId; - } - - if (this.serviceId) { - fields[SERVICE_FIELD.fieldApiName] = this.serviceId; - } - - this.template.querySelector("lightning-record-edit-form").submit(fields); - - this.setSaving(); - } + @api + handleSuccess(savedRow) { + this.recordId = savedRow.id; + this.setSaved(); + this.setDisabledAttribute(); + fireEvent(this.pageRef, "serviceDeliveryUpsert", savedRow); } handleSaveNewPE(event) { @@ -384,6 +442,19 @@ export default class ServiceDeliveryRow extends LightningElement { resetError() { this.isError = false; this.errorMessage = ""; + + if (this.errorByField?.size > 0) { + this.errorByField.keys().forEach(fieldName => { + const input = this.getFieldInput(fieldName); + if (input && typeof input.setErrors === "function") { + input.setErrors(""); + } else if (input && typeof input.setCustomValidity === "function") { + input.setCustomValidity(""); + input.reportValidity(); + } + }); + } + this.errorByField = undefined; } resetQuantityLabel() { @@ -569,6 +640,7 @@ export default class ServiceDeliveryRow extends LightningElement { } } + @api setSaving() { this.saveMessage = "..."; this.isSaving = true; diff --git a/force-app/main/default/lwc/serviceScheduleCreator/serviceScheduleCreator.js b/force-app/main/default/lwc/serviceScheduleCreator/serviceScheduleCreator.js index 17eb6be17..7297d8a68 100644 --- a/force-app/main/default/lwc/serviceScheduleCreator/serviceScheduleCreator.js +++ b/force-app/main/default/lwc/serviceScheduleCreator/serviceScheduleCreator.js @@ -354,8 +354,8 @@ export default class ServiceScheduleCreator extends NavigationMixin(LightningEle this.navigate(); } else { this.init(); + this.dispatchEvent(new CustomEvent("close", { bubbles: true })); } - this.dispatchEvent(new CustomEvent("close", { bubbles: true })); } reset() { @@ -414,6 +414,15 @@ export default class ServiceScheduleCreator extends NavigationMixin(LightningEle actionName: "view", }, }); + this.dispatchEvent( + new CustomEvent("navigate", { + bubbles: true, + detail: { + recordId, + objectApiName, + }, + }) + ); } navigateToList() { diff --git a/force-app/main/default/lwc/serviceScheduleCreatorModal/serviceScheduleCreatorModal.html b/force-app/main/default/lwc/serviceScheduleCreatorModal/serviceScheduleCreatorModal.html new file mode 100644 index 000000000..534d0c258 --- /dev/null +++ b/force-app/main/default/lwc/serviceScheduleCreatorModal/serviceScheduleCreatorModal.html @@ -0,0 +1,12 @@ + diff --git a/force-app/main/default/lwc/serviceScheduleCreatorModal/serviceScheduleCreatorModal.js b/force-app/main/default/lwc/serviceScheduleCreatorModal/serviceScheduleCreatorModal.js new file mode 100644 index 000000000..d166a274c --- /dev/null +++ b/force-app/main/default/lwc/serviceScheduleCreatorModal/serviceScheduleCreatorModal.js @@ -0,0 +1,28 @@ +import { api } from "lwc"; +import LightningModal from "lightning/modal"; +import { NavigationMixin } from "lightning/navigation"; + +import newServiceSchedule from "@salesforce/label/c.New_Service_Schedule"; + +export default class ServiceScheduleCreatorModal extends NavigationMixin(LightningModal) { + labels = { + newServiceSchedule, + }; + @api serviceId; + @api recordTypeId; + @api isCommunity = false; + + handleNavigate(event) { + const recordId = event.detail.recordId; + const objectApiName = event.detail.objectApiName; + this.dispatchEvent( + new CustomEvent("navigate", { + detail: { + recordId, + objectApiName, + }, + }) + ); + this.close("success"); + } +} diff --git a/force-app/main/default/lwc/serviceScheduleCreatorModal/serviceScheduleCreatorModal.js-meta.xml b/force-app/main/default/lwc/serviceScheduleCreatorModal/serviceScheduleCreatorModal.js-meta.xml new file mode 100644 index 000000000..eac275d08 --- /dev/null +++ b/force-app/main/default/lwc/serviceScheduleCreatorModal/serviceScheduleCreatorModal.js-meta.xml @@ -0,0 +1,5 @@ + + + 55.0 + false + \ No newline at end of file diff --git a/force-app/main/default/lwc/serviceScheduleCreatorWrapper/__tests__/serviceScheduleCreatorWrapper.test.js b/force-app/main/default/lwc/serviceScheduleCreatorWrapper/__tests__/serviceScheduleCreatorWrapper.test.js index 6d5ea3df7..e5f96a425 100644 --- a/force-app/main/default/lwc/serviceScheduleCreatorWrapper/__tests__/serviceScheduleCreatorWrapper.test.js +++ b/force-app/main/default/lwc/serviceScheduleCreatorWrapper/__tests__/serviceScheduleCreatorWrapper.test.js @@ -21,22 +21,6 @@ describe("c-service-schedule-creator", () => { }); }); - it("modal appears in non-experience-cloud context and element is accessible", () => { - element.isCommunity = false; - document.body.appendChild(element); - - return global.flushPromises().then(async () => { - const modal = element.shadowRoot.querySelector("c-modal"); - - // Modal will only display with a spinner loaded - expect(modal).not.toBeNull(); - modal.dispatchEvent(new CustomEvent("dialogclose")); - - // TODO: Validate accessibility when each step is loads. - global.isAccessible(element); - }); - }); - it("modal does not appear in experience-cloud context and element is accessible", () => { element.isCommunity = true; document.body.appendChild(element); diff --git a/force-app/main/default/lwc/serviceScheduleCreatorWrapper/serviceScheduleCreatorWrapper.html b/force-app/main/default/lwc/serviceScheduleCreatorWrapper/serviceScheduleCreatorWrapper.html index 9da97955c..b6b40c2b4 100644 --- a/force-app/main/default/lwc/serviceScheduleCreatorWrapper/serviceScheduleCreatorWrapper.html +++ b/force-app/main/default/lwc/serviceScheduleCreatorWrapper/serviceScheduleCreatorWrapper.html @@ -8,20 +8,6 @@ -->