diff --git a/framework/default/fflib/default/classes/common/fflib_SObjectUnitOfWork.cls b/framework/default/fflib/default/classes/common/fflib_SObjectUnitOfWork.cls index 324265def06..24c8385b501 100644 --- a/framework/default/fflib/default/classes/common/fflib_SObjectUnitOfWork.cls +++ b/framework/default/fflib/default/classes/common/fflib_SObjectUnitOfWork.cls @@ -2,22 +2,22 @@ * Copyright (c), FinancialForce.com, inc * All rights reserved. * - * Redistribution and use in source and binary forms, with or without modification, + * Redistribution and use in source and binary forms, with or without modification, * are permitted provided that the following conditions are met: * - * - Redistributions of source code must retain the above copyright notice, + * - Redistributions of source code must retain the above copyright notice, * this list of conditions and the following disclaimer. - * - Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation * and/or other materials provided with the distribution. - * - Neither the name of the FinancialForce.com, inc nor the names of its contributors - * may be used to endorse or promote products derived from this software without + * - Neither the name of the FinancialForce.com, inc nor the names of its contributors + * may be used to endorse or promote products derived from this software without * specific prior written permission. * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES - * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL - * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES + * OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL + * THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, * EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY * OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) @@ -367,19 +367,26 @@ public virtual class fflib_SObjectUnitOfWork { if (record.Id == null) throw new UnitOfWorkException('New records cannot be registered as dirty'); - String sObjectType = record.getSObjectType().getDescribe().getName(); + + SobjectType oSobjectType = record.getSObjectType(); + String sObjectType = oSobjectType.getDescribe().getName(); assertForNonEventSObjectType(sObjectType); assertForSupportedSObjectType(m_dirtyMapByType, sObjectType); // If record isn't registered as dirty, or no dirty fields to drive a merge - if (!m_dirtyMapByType.get(sObjectType).containsKey(record.Id) || dirtyFields.isEmpty()) - { - // Register the record as dirty - m_dirtyMapByType.get(sObjectType).put(record.Id, record); - } - else - { + if ( dirtyFields.isEmpty() ) + { + m_dirtyMapByType.get(sObjectType).put(record.Id, record); + } + else + { + if ( !m_dirtyMapByType.get(sObjectType).containsKey(record.Id) ) + { + // If the record isn't already there, put a new skeleton one in there + m_dirtyMapByType.get(sObjectType).put(record.Id, oSobjectType.newSObject( record.id ) ); + } + // Update the registered record's fields SObject registeredRecord = m_dirtyMapByType.get(sObjectType).get(record.Id); @@ -426,7 +433,7 @@ public virtual class fflib_SObjectUnitOfWork **/ public void registerUpsert(SObject record) { - if (record.Id == null) + if (record.Id == null) { registerNew(record, null, null); } diff --git a/framework/default/fflib/default/classes/common/tests/fflib_SObjectUnitOfWorkTest.cls b/framework/default/fflib/default/classes/common/tests/fflib_SObjectUnitOfWorkTest.cls index 22081f9c968..c27e794ff3c 100644 --- a/framework/default/fflib/default/classes/common/tests/fflib_SObjectUnitOfWorkTest.cls +++ b/framework/default/fflib/default/classes/common/tests/fflib_SObjectUnitOfWorkTest.cls @@ -397,6 +397,35 @@ private with sharing class fflib_SObjectUnitOfWorkTest System.assertEquals('Expected', mockDML.recordsForUpdate.get(0).get(Schema.Opportunity.Name)); } + /** + * Try registering a single field as dirty on first call + * + * Testing: + * + * - only that field is updated + */ + @IsTest + private static void testRegisterDirtyOnce_field() { + Opportunity opp = new Opportunity( + Id = fflib_IDGenerator.generate(Schema.Opportunity.SObjectType), + Name = 'test name', + StageName = 'Open', + CloseDate = System.today()); + + Opportunity amountUpdate = new Opportunity(Id = opp.Id, Name = 'ShouldNotAppear', Amount = 250); + MockDML mockDML = new MockDML(); + fflib_SObjectUnitOfWork uow = new fflib_SObjectUnitOfWork(MY_SOBJECTS, mockDML); + uow.registerDirty(amountUpdate, new List { Opportunity.Amount } ); + uow.commitWork(); + + System.assertEquals(1, mockDML.recordsForUpdate.size()); + + System.assertEquals(true, mockDML.recordsForUpdate.get(0).getPopulatedFieldsAsMap().containsKey( 'Amount' )); + System.assertEquals(false, mockDML.recordsForUpdate.get(0).getPopulatedFieldsAsMap().containsKey( 'Name' )); + + System.assertEquals(amountUpdate.Amount, mockDML.recordsForUpdate.get(0).get(Schema.Opportunity.Amount)); + + } /** * Try registering a single field as dirty. * diff --git a/framework/default/ortoo-core/default/classes/ApplicationMockRegistrar.cls b/framework/default/ortoo-core/default/classes/ApplicationMockRegistrar.cls index 8e1bf17c6d7..26ec16d457a 100644 --- a/framework/default/ortoo-core/default/classes/ApplicationMockRegistrar.cls +++ b/framework/default/ortoo-core/default/classes/ApplicationMockRegistrar.cls @@ -204,7 +204,6 @@ public inherited sharing class ApplicationMockRegistrar * @param Type The type (interface) for which the mock app logic class should be registered * @return Amoss_Instance The controller for the mock Service */ - // TODO: test public static Amoss_Instance registerMockAppLogic( Type appLogicType ) { Amoss_Instance mockAppLogicController = new Amoss_Instance( appLogicType ); diff --git a/framework/default/ortoo-core/default/classes/fflib-extension/SecureDml.cls b/framework/default/ortoo-core/default/classes/fflib-extension/SecureDml.cls index 6900822e2ae..92a05ca9674 100644 --- a/framework/default/ortoo-core/default/classes/fflib-extension/SecureDml.cls +++ b/framework/default/ortoo-core/default/classes/fflib-extension/SecureDml.cls @@ -617,8 +617,9 @@ public inherited sharing virtual class SecureDml extends fflib_SobjectUnitOfWork String label = Label.ortoo_core_fls_violation; String modeDescription = descriptionByMode.get( mode ); String sobjectTypeName = SobjectUtils.getSobjectName( sobjectType ); + String fieldsInViolationString = String.join( ListUtils.convertToListOfStrings( fieldsInViolation ), ', ' ); - throw new SecureDmlException( StringUtils.formatLabel( label, new List{ modeDescription, sobjectTypeName, fieldsInViolation.toString() } ) ) + throw new SecureDmlException( StringUtils.formatLabel( label, new List{ modeDescription, sobjectTypeName, fieldsInViolationString } ) ) .setErrorCode( FrameworkErrorCodes.DML_ON_INACCESSIBLE_FIELDS ) .addContext( 'sobjectTypeName', sobjectTypeName ) .addContext( 'fieldsInViolation', fieldsInViolation ) diff --git a/framework/default/ortoo-core/default/classes/null-objects/tests/NullDomainTest.cls b/framework/default/ortoo-core/default/classes/null-objects/tests/NullDomainTest.cls index d57a00b9f34..6deadd6960b 100644 --- a/framework/default/ortoo-core/default/classes/null-objects/tests/NullDomainTest.cls +++ b/framework/default/ortoo-core/default/classes/null-objects/tests/NullDomainTest.cls @@ -7,7 +7,7 @@ private without sharing class NullDomainTest private static void constructorClass_buildsANullDomain() // NOPMD: Test method name format { NullDomain nullDomain = (NullDomain)new NullDomain.Constructor().construct( LIST_OF_SOBJECTS ); - System.assertNotEquals( null, nullDomain, 'constructor, when called, does not throw an exception' ); + System.assertNotEquals( null, nullDomain, 'constructor method, when called, will not throw an exception and returns a non null NullDomain' ); } @isTest diff --git a/framework/default/ortoo-core/default/classes/services/dml/DmlRecord.cls b/framework/default/ortoo-core/default/classes/services/dml/DmlRecord.cls index d331046d0d4..5f6a8316dba 100644 --- a/framework/default/ortoo-core/default/classes/services/dml/DmlRecord.cls +++ b/framework/default/ortoo-core/default/classes/services/dml/DmlRecord.cls @@ -39,6 +39,17 @@ public virtual inherited sharing class DmlRecord return this.recordToDml; } + /** + * Returns the Id that is on the SObject this will perform the DML against. + * Is populated after an insert has been performed + * + * @return Id The Id on the SObject + */ + public Id getSobjectId() + { + return this.recordToDml.Id; + } + /** * Defines the context of a 'type' of child, thus allowing children of that type to be added * diff --git a/framework/default/ortoo-core/default/classes/services/dml/tests/DmlRecordTest.cls b/framework/default/ortoo-core/default/classes/services/dml/tests/DmlRecordTest.cls index fb1151e9174..13d52546d74 100644 --- a/framework/default/ortoo-core/default/classes/services/dml/tests/DmlRecordTest.cls +++ b/framework/default/ortoo-core/default/classes/services/dml/tests/DmlRecordTest.cls @@ -30,6 +30,20 @@ private without sharing class DmlRecordTest ortoo_Asserts.assertContains( 'DmlRecord constructor called with a null recordToDml', exceptionMessage, 'constructor, when given null, will throw an exception' ); } + @isTest + private static void getSobjectId_willReturnTheIdOfTheSobject() // NOPMD: Test method name format + { + Id accountId = TestIdUtils.generateId( Account.sobjectType ); + + DmlRecord dmlRecord = new DmlRecord( new Account( Id = accountId ) ); + + Test.startTest(); + Id got = dmlRecord.getSobjectId(); + Test.stopTest(); + + System.assertEquals( accountId, got, 'getSobjectId, will return the Id of the SObject' ); + } + @isTest private static void addChildContect_whenCalled_willInitialiseADefinerForThatType() // NOPMD: Test method name format { diff --git a/framework/default/ortoo-core/default/classes/test-utils/TestIdUtils.cls b/framework/default/ortoo-core/default/classes/test-utils/TestIdUtils.cls index 8edd2a033b3..ddd44f96b15 100644 --- a/framework/default/ortoo-core/default/classes/test-utils/TestIdUtils.cls +++ b/framework/default/ortoo-core/default/classes/test-utils/TestIdUtils.cls @@ -4,7 +4,7 @@ * @group Utils */ @isTest -public with sharing class TestIdUtils +public inherited sharing class TestIdUtils { private static Integer highestGeneratedId = 0; private static Map objectPrefixesBySobjectType = new Map(); diff --git a/framework/default/ortoo-core/default/classes/utils/ListUtils.cls b/framework/default/ortoo-core/default/classes/utils/ListUtils.cls index 4fc42759ef6..58b1ad9ea0f 100644 --- a/framework/default/ortoo-core/default/classes/utils/ListUtils.cls +++ b/framework/default/ortoo-core/default/classes/utils/ListUtils.cls @@ -137,4 +137,19 @@ public inherited sharing class ListUtils } return returnList; } + + /** + * Given a Set of any Strings, will return a List of those Strings + * + * @param Set The set of strings to convert + * @return List The converted list + */ + public static List convertToListOfStrings( Set setOfStrings ) + { + if ( setOfStrings == null ) + { + return new List(); + } + return new List( setOfStrings ); + } } \ No newline at end of file diff --git a/framework/default/ortoo-core/default/classes/utils/tests/ListUtilsTest.cls b/framework/default/ortoo-core/default/classes/utils/tests/ListUtilsTest.cls index 9dc52c2313c..28f3f423552 100644 --- a/framework/default/ortoo-core/default/classes/utils/tests/ListUtilsTest.cls +++ b/framework/default/ortoo-core/default/classes/utils/tests/ListUtilsTest.cls @@ -2,7 +2,7 @@ private without sharing class ListUtilsTest { @isTest - private static void slice_whenGivenListAndPositiveStartAndEnd_willReturnASubsetList() // NOPMD: Test method name format + private static void slice_whenGivenListAndPositiveStartAndEnd_returnsASubsetList() // NOPMD: Test method name format { List originalList = new List{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; @@ -26,7 +26,7 @@ private without sharing class ListUtilsTest } @isTest - private static void slice_whenGivenListAndPositiveStartAndEndTheSame_willReturnAnEmptyList() // NOPMD: Test method name format + private static void slice_whenGivenListAndPositiveStartAndEndTheSame_returnsAnEmptyList() // NOPMD: Test method name format { List originalList = new List{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; @@ -50,7 +50,7 @@ private without sharing class ListUtilsTest } @isTest - private static void slice_whenGivenListAndNegativeStartAndEndTheSame_willReturnAnEmptyList() // NOPMD: Test method name format + private static void slice_whenGivenListAndNegativeStartAndEndTheSame_returnsAnEmptyList() // NOPMD: Test method name format { List originalList = new List{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; @@ -74,7 +74,7 @@ private without sharing class ListUtilsTest } @isTest - private static void slice_whenGivenStartBeyondTheEnd_willReturnAnEmptyList() // NOPMD: Test method name format + private static void slice_whenGivenStartBeyondTheEnd_returnsAnEmptyList() // NOPMD: Test method name format { List originalList = new List{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; @@ -86,7 +86,7 @@ private without sharing class ListUtilsTest } @isTest - private static void slice_whenGivenListAEndBeyondTheEnd_willReturnASubsetListToTheEnd() // NOPMD: Test method name format + private static void slice_whenGivenListAEndBeyondTheEnd_returnsASubsetListToTheEnd() // NOPMD: Test method name format { List originalList = new List{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; @@ -98,7 +98,7 @@ private without sharing class ListUtilsTest } @isTest - private static void slice_whenGivenListAndNegativeStartBeyondTheStart_willReturnASubsetListFromTheStart() // NOPMD: Test method name format + private static void slice_whenGivenListAndNegativeStartBeyondTheStart_returnsASubsetListFromTheStart() // NOPMD: Test method name format { List originalList = new List{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; @@ -110,7 +110,7 @@ private without sharing class ListUtilsTest } @isTest - private static void slice_whenGivenNegativeEndBeyondTheStartofTheList_willReturnAnEmptyList() // NOPMD: Test method name format + private static void slice_whenGivenNegativeEndBeyondTheStartofTheList_returnsAnEmptyList() // NOPMD: Test method name format { List originalList = new List{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; @@ -122,7 +122,7 @@ private without sharing class ListUtilsTest } @isTest - private static void slice_whenGivenAPositiveEndBeforeThePositiveStart_willReturnAnEmptyList() // NOPMD: Test method name format + private static void slice_whenGivenAPositiveEndBeforeThePositiveStart_returnsAnEmptyList() // NOPMD: Test method name format { List originalList = new List{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; @@ -134,7 +134,7 @@ private without sharing class ListUtilsTest } @isTest - private static void slice_whenGivenANegativeEndBeforeThePositiveStart_willReturnAnEmptyList() // NOPMD: Test method name format + private static void slice_whenGivenANegativeEndBeforeThePositiveStart_returnsAnEmptyList() // NOPMD: Test method name format { List originalList = new List{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; @@ -146,7 +146,7 @@ private without sharing class ListUtilsTest } @isTest - private static void slice_whenGivenAPositiveEndBeforeTheNegativeStart_willReturnAnEmptyList() // NOPMD: Test method name format + private static void slice_whenGivenAPositiveEndBeforeTheNegativeStart_returnsAnEmptyList() // NOPMD: Test method name format { List originalList = new List{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; @@ -158,7 +158,7 @@ private without sharing class ListUtilsTest } @isTest - private static void slice_whenGivenANegativeEndBeforeTheNegativeStart_willReturnAnEmptyList() // NOPMD: Test method name format + private static void slice_whenGivenANegativeEndBeforeTheNegativeStart_returnsAnEmptyList() // NOPMD: Test method name format { List originalList = new List{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; @@ -215,7 +215,7 @@ private without sharing class ListUtilsTest } @isTest - private static void trim_whenGivenListAndPositiveLength_willReturnASubsetList() // NOPMD: Test method name format + private static void trim_whenGivenListAndPositiveLength_returnsASubsetList() // NOPMD: Test method name format { List originalList = new List{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; @@ -227,7 +227,7 @@ private without sharing class ListUtilsTest } @isTest - private static void trim_whenGivenListAndNegativeLength_willReturnASubsetList() // NOPMD: Test method name format + private static void trim_whenGivenListAndNegativeLength_returnsASubsetList() // NOPMD: Test method name format { List originalList = new List{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; @@ -239,7 +239,7 @@ private without sharing class ListUtilsTest } @isTest - private static void trim_whenGivenListAndPositiveLengthGreaterThanTheLength_willReturnTheFullList() // NOPMD: Test method name format + private static void trim_whenGivenListAndPositiveLengthGreaterThanTheLength_returnsTheFullList() // NOPMD: Test method name format { List originalList = new List{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; @@ -249,7 +249,7 @@ private without sharing class ListUtilsTest } @isTest - private static void trim_whenGivenListAndNegativeLengthGreaterThanTheLength_willReturnTheFullList() // NOPMD: Test method name format + private static void trim_whenGivenListAndNegativeLengthGreaterThanTheLength_returnsTheFullList() // NOPMD: Test method name format { List originalList = new List{ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 }; @@ -289,7 +289,7 @@ private without sharing class ListUtilsTest } @isTest - private static void getNumberOfUniqueIds_whenGivenAListAndIdField_willReturnACountOfTheUniqueIds() // NOPMD: Test method name format + private static void getNumberOfUniqueIds_whenGivenAListAndIdField_returnsACountOfTheUniqueIds() // NOPMD: Test method name format { List accountIds = new List{ TestIdUtils.generateId( Account.getSObjectType() ), @@ -313,7 +313,7 @@ private without sharing class ListUtilsTest } @isTest - private static void getNumberOfUniqueIds_whenAFieldThatIsNotAnId_willReturnACountOfTheUniqueIds() // NOPMD: Test method name format + private static void getNumberOfUniqueIds_whenAFieldThatIsNotAnId_returnsACountOfTheUniqueIds() // NOPMD: Test method name format { List accountIds = new List{ TestIdUtils.generateId( Account.getSObjectType() ), @@ -337,7 +337,7 @@ private without sharing class ListUtilsTest } @isTest - private static void getNumberOfUniqueIds_whenAFieldThatIsNotAnIdAndContainsNonIdData_willReturnACountOfTheUniqueIds() // NOPMD: Test method name format + private static void getNumberOfUniqueIds_whenAFieldThatIsNotAnIdAndContainsNonIdData_returnsACountOfTheUniqueIds() // NOPMD: Test method name format { List accountIds = new List{ TestIdUtils.generateId( Account.getSObjectType() ), @@ -419,7 +419,7 @@ private without sharing class ListUtilsTest } @isTest - private static void convertToListOfStrings_whenGivenAListOfObjects_willReturnAListWithTheStringVersionsOfThem() // NOPMD: Test method name format + private static void convertToListOfStrings_listObject_whenGivenAListOfObjects_returnsAListOfStrings() // NOPMD: Test method name format { List listOfObjects = new List{ new StringableThing( 'one' ), @@ -437,7 +437,7 @@ private without sharing class ListUtilsTest } @isTest - private static void convertToListOfStrings_whenGivenAnEmptyListOfObjects_willReturnAAnEmptyList() // NOPMD: Test method name format + private static void convertToListOfStrings_listObject_whenGivenAnEmptyListOfObjects_returnsAnEmptyList() // NOPMD: Test method name format { List emptyListOfObjects = new List(); @@ -449,13 +449,53 @@ private without sharing class ListUtilsTest } @isTest - private static void convertToListOfStrings_whenGivenNull_willReturnAAnEmptyList() // NOPMD: Test method name format + private static void convertToListOfStrings_listObject_whenGivenNull_returnsAnEmptyList() // NOPMD: Test method name format { + List nullListOfObjects = null; + + Test.startTest(); + List returnedList = ListUtils.convertToListOfStrings( nullListOfObjects ); + Test.stopTest(); + + System.assertEquals( new List(), returnedList, 'convertToListOfStrings, when given null list of objects, will return an empty list' ); + } + + @isTest + private static void convertToListOfStrings_setString_whenGivenASetOfStrings_returnsAListOfString() // NOPMD: Test method name format + { + Set setOfStrings = new Set{ 'one', 'two', 'three' }; + + Test.startTest(); + List returnedList = ListUtils.convertToListOfStrings( setOfStrings ); + Test.stopTest(); + + List expectedList = new List{ 'one', 'two', 'three' }; + + System.assertEquals( expectedList, returnedList, 'convertToListOfStrings, when given a set of strings, will return a list of the strings' ); + } + + @isTest + private static void convertToListOfStrings_setString_whenGivenAnEmptySet_returnsAnEmptySet() // NOPMD: Test method name format + { + Set emptySetOfStrings = new Set(); + + Test.startTest(); + List returnedList = ListUtils.convertToListOfStrings( emptySetOfStrings ); + Test.stopTest(); + + System.assertEquals( new List(), returnedList, 'convertToListOfStrings, when given an empty set of strings, will return an empty list' ); + } + + @isTest + private static void convertToListOfStrings_setString_whenGivenNull_returnsAnEmptyList() // NOPMD: Test method name format + { + Set nullSetOfStrings = null; + Test.startTest(); - List returnedList = ListUtils.convertToListOfStrings( null ); + List returnedList = ListUtils.convertToListOfStrings( nullSetOfStrings ); Test.stopTest(); - System.assertEquals( new List(), returnedList, 'convertToListOfStrings, when given null, will return an empty list' ); + System.assertEquals( new List(), returnedList, 'convertToListOfStrings, when given null set of strings, will return an empty list' ); } private inherited sharing class StringableThing { diff --git a/framework/default/ortoo-core/default/labels/ortoo-core-CustomLabels.labels-meta.xml b/framework/default/ortoo-core/default/labels/ortoo-core-CustomLabels.labels-meta.xml index 262bfe59000..b29a1ad0a31 100644 --- a/framework/default/ortoo-core/default/labels/ortoo-core-CustomLabels.labels-meta.xml +++ b/framework/default/ortoo-core/default/labels/ortoo-core-CustomLabels.labels-meta.xml @@ -33,7 +33,7 @@ en_US false Message when FLS violation occurs on a DML operation. - Attempted to {0} {1} with fields that are not accessible: {2} + Attempted to {0} {1} with fields that are not accessible / updateable: {2} ortoo_core_crud_insert_violation @@ -112,6 +112,20 @@ The word 'Edit', capitalised. Edit + + ortoo_core_delete + en_US + false + The word 'Delete', capitalised. + Delete + + + ortoo_core_ok + en_US + false + The word 'OK'. + OK + ortoo_core_loading en_US diff --git a/framework/default/ortoo-core/default/lwc/datatableHelper/__tests__/datatableHelper.test.js b/framework/default/ortoo-core/default/lwc/datatableHelper/__tests__/datatableHelper.test.js index d69626c5e2f..ababb6350b7 100644 --- a/framework/default/ortoo-core/default/lwc/datatableHelper/__tests__/datatableHelper.test.js +++ b/framework/default/ortoo-core/default/lwc/datatableHelper/__tests__/datatableHelper.test.js @@ -246,3 +246,223 @@ describe( 'configureSortableFields', () => { expect( displayError.mock.calls[0][1]?.title ).toBe( 'Sorting configuration error' ); }); }); + +describe( 'configureLabelsBasedOnSobjectDefinition', () => { + + it( 'when bound to an object with a columns definition, the name of the right property and a definition with some fields that have labels', () => { + + const columns = [ + { label: 'original', fieldName: 'contact-firstname', labelSobject: 'Contact', labelSobjectField: 'FirstName' }, + { label: 'original', fieldName: 'contact-lastname', labelSobject: 'Contact', labelSobjectField: 'LastName' }, + { label: 'original', fieldName: 'account-name', labelSobject: 'Account', labelSobjectField: 'Name' }, + { label: 'original', fieldName: 'contact-name', labelSobject: 'Contact', labelSobjectField: 'Name' }, + ]; + + const objectToRunAgainst = { + columnsProperty: columns + }; + + const sobjectDefinition = { + apiName: 'Contact', + fields: { + FirstName: { + label: 'First Name Label' + }, + LastName: { + label: 'Last Name Label' + }, + Name: { + label: 'Name Label' + }, + } + } + + DatatableHelper.configureLabelsBasedOnSobjectDefinition.call( objectToRunAgainst, 'columnsProperty', sobjectDefinition ); + + expect( objectToRunAgainst.columnsProperty[0].label ).toBe( 'First Name Label' ); + expect( objectToRunAgainst.columnsProperty[1].label ).toBe( 'Last Name Label' ); + expect( objectToRunAgainst.columnsProperty[2].label ).toBe( 'original' ); + expect( objectToRunAgainst.columnsProperty[3].label ).toBe( 'Name Label' ); + }); + + it( 'when columns include labelSobject and labelSobjectField combinations that do not exist, will ignore them', () => { + + const columns = [ + { label: 'original', fieldName: 'contact-firstname', labelSobject: 'Contact', labelSobjectField: 'FirstName' }, + { label: 'original', fieldName: 'contact-lastname', labelSobject: 'Contact', labelSobjectField: 'LastName' }, + { label: 'original', fieldName: 'account-name', labelSobject: 'Account', labelSobjectField: 'Name' }, + { label: 'original', fieldName: 'contact-name', labelSobject: 'Contact', labelSobjectField: 'Name' }, + ]; + + const objectToRunAgainst = { + columnsProperty: columns + }; + + const sobjectDefinition = { + apiName: 'Contact', + fields: { + LastName: { + label: 'Last Name Label' + }, + } + } + + DatatableHelper.configureLabelsBasedOnSobjectDefinition.call( objectToRunAgainst, 'columnsProperty', sobjectDefinition ); + + expect( objectToRunAgainst.columnsProperty[0].label ).toBe( 'original' ); + expect( objectToRunAgainst.columnsProperty[1].label ).toBe( 'Last Name Label' ); + expect( objectToRunAgainst.columnsProperty[2].label ).toBe( 'original' ); + expect( objectToRunAgainst.columnsProperty[3].label ).toBe( 'original' ); + }); + + it( 'when columns include entries with no labelSobject or labelSobjectField, will ignore them', () => { + + const columns = [ + { label: 'original', fieldName: 'ignore-no-properties' }, + { label: 'original', fieldName: 'ignore-noobject',labelSobjectField: 'LastName' }, + { label: 'original', fieldName: 'ignore-nofield', labelSobject: 'Contact' }, + { label: 'original', fieldName: 'contact-name', labelSobject: 'Contact', labelSobjectField: 'Name' }, + ]; + + const objectToRunAgainst = { + columnsProperty: columns + }; + + const sobjectDefinition = { + apiName: 'Contact', + fields: { + Name: { + label: 'Name Label' + }, + } + } + + DatatableHelper.configureLabelsBasedOnSobjectDefinition.call( objectToRunAgainst, 'columnsProperty', sobjectDefinition ); + + expect( objectToRunAgainst.columnsProperty[0].label ).toBe( 'original' ); + expect( objectToRunAgainst.columnsProperty[1].label ).toBe( 'original' ); + expect( objectToRunAgainst.columnsProperty[2].label ).toBe( 'original' ); + expect( objectToRunAgainst.columnsProperty[3].label ).toBe( 'Name Label' ); + }); + + it( 'when the sobjectDefinition is null', () => { + + const columns = [ + { label: 'alabel', fieldName: 'afield' }, + ]; + + const objectToRunAgainst = { + columnsProperty: columns + }; + + DatatableHelper.configureLabelsBasedOnSobjectDefinition.call( objectToRunAgainst, 'columnsProperty', null ); + + expect( objectToRunAgainst.columnsProperty.length ).toBe( 1 ); + }); + + it( 'when the column property is empty, will not throw', () => { + + const columns = [ + ]; + + const objectToRunAgainst = { + columnsProperty: columns + }; + + const sobjectDefinition = { + apiName: 'Contact', + fields: { + Name: { + label: 'Name Label' + }, + } + } + + DatatableHelper.configureLabelsBasedOnSobjectDefinition.call( objectToRunAgainst, 'columnsProperty', sobjectDefinition ); + + expect( objectToRunAgainst.columnsProperty.length ).toBe( 0 ); + }); + + it( 'when called without being bound to an object, will throw', () => { + + const sobjectDefinition = { + apiName: 'Contact', + fields: { + Name: { + label: 'Name Label' + }, + } + } + + const call = () => DatatableHelper.configureLabelsBasedOnSobjectDefinition( 'columnsProperty', sobjectDefinition ); + + expect( call ).toThrow( 'configureLabelsBasedOnSobjectDefinition called with a property (columnsProperty) that does not exist. Have you bound your instance by using "bind" or "call"?' ); + }); + + it( 'when the passed column property does not exist on the bound object, will throw', () => { + + const objectToRunAgainst = {}; + + const sobjectDefinition = { + apiName: 'Contact', + fields: { + Name: { + label: 'Name Label' + }, + } + } + + const call = () => DatatableHelper.configureLabelsBasedOnSobjectDefinition.call( objectToRunAgainst, 'columnsProperty', sobjectDefinition ); + + expect( call ).toThrow( 'configureLabelsBasedOnSobjectDefinition called with a property (columnsProperty) that does not exist.' ); + }); + + it( 'when the passed column property is not an array, will throw', () => { + + const columns = 'this is not an array'; + + const objectToRunAgainst = { + columnsProperty: columns + }; + + const sobjectDefinition = { + apiName: 'Contact', + fields: { + Name: { + label: 'Name Label' + }, + } + } + + const call = () => DatatableHelper.configureLabelsBasedOnSobjectDefinition.call( objectToRunAgainst, 'columnsProperty', sobjectDefinition ); + + expect( call ).toThrow( 'configureLabelsBasedOnSobjectDefinition called with a property (columnsProperty) that is not an Array.' ); + }); + + it( 'when the passed column property is not an array of objects, will throw', () => { + + const columns = [ + {}, + 'this is not an object', + {} + ]; + + const objectToRunAgainst = { + columnsProperty: columns + }; + + const sobjectDefinition = { + apiName: 'Contact', + fields: { + Name: { + label: 'Name Label' + }, + } + } + + const call = () => DatatableHelper.configureLabelsBasedOnSobjectDefinition.call( objectToRunAgainst, 'columnsProperty', sobjectDefinition ); + + expect( call ).toThrow( 'configureLabelsBasedOnSobjectDefinition called with a property (columnsProperty) that is not an Array of Objects.' ); + }); + +}); diff --git a/framework/default/ortoo-core/default/lwc/datatableHelper/datatableHelper.js b/framework/default/ortoo-core/default/lwc/datatableHelper/datatableHelper.js index cecf7b591a2..dc663ca03ba 100644 --- a/framework/default/ortoo-core/default/lwc/datatableHelper/datatableHelper.js +++ b/framework/default/ortoo-core/default/lwc/datatableHelper/datatableHelper.js @@ -16,7 +16,6 @@ const checkColumnProperty = function( functionName, object, columnsPropertyName } } - const refreshConfiguration = function( columnsPropertyName ) { checkColumnProperty( 'refreshConfiguration', this, columnsPropertyName ); @@ -53,7 +52,21 @@ const configureSortableFields = function( columnsPropertyName, fields, error ) { } } +const configureLabelsBasedOnSobjectDefinition = function( columnsPropertyName, sobjectDefinition ) +{ + checkColumnProperty( 'configureLabelsBasedOnSobjectDefinition', this, columnsPropertyName ); + + if ( sobjectDefinition ) { + this[columnsPropertyName].forEach( thisColumn => { + ( thisColumn.labelSobject == sobjectDefinition.apiName ) + && ( sobjectDefinition.fields[ thisColumn.labelSobjectField ] ) + && ( thisColumn.label = sobjectDefinition.fields[ thisColumn.labelSobjectField ].label ); + }); + } +} + export default { refreshConfiguration : refreshConfiguration, - configureSortableFields : configureSortableFields + configureSortableFields : configureSortableFields, + configureLabelsBasedOnSobjectDefinition : configureLabelsBasedOnSobjectDefinition, }; \ No newline at end of file diff --git a/framework/default/ortoo-core/default/lwc/errorRenderer/__tests__/errorRenderer.test.js b/framework/default/ortoo-core/default/lwc/errorRenderer/__tests__/errorRenderer.test.js index 8bd42af27d0..cb130dd01d8 100644 --- a/framework/default/ortoo-core/default/lwc/errorRenderer/__tests__/errorRenderer.test.js +++ b/framework/default/ortoo-core/default/lwc/errorRenderer/__tests__/errorRenderer.test.js @@ -68,7 +68,7 @@ describe('displayError', () => { expect( reportedError ).toBe( error ); }); - it( 'When the passed error is an Apex exception object, will raise a toast with the message as the given text', () => { + it( 'When the passed error is an Apex exception object with a body.message, will raise a toast with the message as the given text', () => { console.error = jest.fn(); @@ -99,6 +99,49 @@ describe('displayError', () => { expect( reportedError ).toBe( error ); }); + it( 'When the passed error is an Apex exception object with a body.message with pageErrors, fieldErrors and duplicateResults, will raise a toast with the messages combined as the given text', () => { + + console.error = jest.fn(); + + const objectToRunAgainst = { + dispatchEvent: jest.fn() + }; + + const error = { + body: { + message: 'body message 1', + pageErrors: [ + { message: 'page error 1' }, + { message: 'page error 2' }, + ], + fieldErrors: [ + { message: 'field error 1' }, + { message: 'field error 2' }, + ], + duplicateResults: [ + { message: 'duplicate result 1' }, + { message: 'duplicate result 2' }, + ], + } + } + + displayError.call( objectToRunAgainst, error ); + + expect( objectToRunAgainst.dispatchEvent ).toBeCalled(); + + const dispatchedEvent = objectToRunAgainst.dispatchEvent.mock.calls[0][0]; + + expect( dispatchedEvent.detail.title ).toBe( 'c.ortoo_core_error_title' ); + expect( dispatchedEvent.detail.message ).toBe( 'body message 1, page error 1, page error 2, field error 1, field error 2, duplicate result 1, duplicate result 2' ); + expect( dispatchedEvent.detail.variant ).toBe( 'error' ); + expect( dispatchedEvent.detail.mode ).toBe( 'sticky' ); + + expect( console.error ).toHaveBeenCalledTimes( 1 ); + const reportedError = console.error.mock.calls[0][0]; + + expect( reportedError ).toBe( error ); + }); + it( 'When given an options with a messagePrefix set, will use that to prefix the message and the rest defaulted', () => { console.error = jest.fn(); diff --git a/framework/default/ortoo-core/default/lwc/errorRenderer/errorRenderer.js b/framework/default/ortoo-core/default/lwc/errorRenderer/errorRenderer.js index 65b7a7de876..f56495e3430 100644 --- a/framework/default/ortoo-core/default/lwc/errorRenderer/errorRenderer.js +++ b/framework/default/ortoo-core/default/lwc/errorRenderer/errorRenderer.js @@ -21,9 +21,29 @@ const displayError = function( error, options ) { message = error.message; } - // Apex Exceptions will have body.message set + + // Apex Exceptions will have a body set if ( error.body ) { - message = error.body.message; + + let messages = []; + + if ( error.body.message ) { + messages.push( error.body.message ); + } + + if ( error.body.pageErrors?.length ) { + error.body.pageErrors.forEach( thisError => messages.push( thisError.message ) ); + } + + if ( error.body.fieldErrors?.length ) { + error.body.fieldErrors.forEach( thisError => messages.push( thisError.message ) ); + } + + if ( error.body.duplicateResults?.length ) { + error.body.duplicateResults.forEach( thisError => messages.push( thisError.message ) ); + } + + message = messages.length ? messages.join( ', ' ) : 'Unknown'; } if ( this?.dispatchEvent == undefined ) { diff --git a/framework/default/ortoo-core/default/lwc/objectHelper/__tests__/objectHelper.test.js b/framework/default/ortoo-core/default/lwc/objectHelper/__tests__/objectHelper.test.js new file mode 100644 index 00000000000..2ae043be5f3 --- /dev/null +++ b/framework/default/ortoo-core/default/lwc/objectHelper/__tests__/objectHelper.test.js @@ -0,0 +1,20 @@ +import ObjectHelper from 'c/objectHelper'; + +describe( 'generateId', () => { + + it( 'returns an alpha numeric string that is 10 characters long', () => { + + const got = ObjectHelper.generateId(); + expect( got ).toHaveLength( 10 ); + }); + + it( 'does not return the same string when called multiple times', () => { + + let previousIds = []; + for ( let i=0; i<100; i++ ) { + const got = ObjectHelper.generateId(); + expect( previousIds ).not.toContain( got ); + previousIds.push( got ); + } + }); +}); \ No newline at end of file diff --git a/framework/default/ortoo-core/default/lwc/objectHelper/objectHelper.js b/framework/default/ortoo-core/default/lwc/objectHelper/objectHelper.js new file mode 100644 index 00000000000..55fd50d414e --- /dev/null +++ b/framework/default/ortoo-core/default/lwc/objectHelper/objectHelper.js @@ -0,0 +1,16 @@ +const generateId = function() { + const length = 10; + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + const charactersLength = characters.length; + + let result = ''; + for ( let i = 0; i < length; i++ ) { + result += characters.charAt( Math.floor ( Math.random() * charactersLength ) ); + } + + return result; +} + +export default { + generateId : generateId +}; \ No newline at end of file diff --git a/framework/default/ortoo-core/default/lwc/objectHelper/objectHelper.js-meta.xml b/framework/default/ortoo-core/default/lwc/objectHelper/objectHelper.js-meta.xml new file mode 100644 index 00000000000..3f282860eba --- /dev/null +++ b/framework/default/ortoo-core/default/lwc/objectHelper/objectHelper.js-meta.xml @@ -0,0 +1,5 @@ + + + 54.0 + false + \ No newline at end of file diff --git a/framework/default/ortoo-core/default/lwc/viewAndEditForm/__tests__/viewAndEditForm.test.js b/framework/default/ortoo-core/default/lwc/viewAndEditForm/__tests__/viewAndEditForm.test.js index 7d7e7f434e8..de4f3df58d9 100644 --- a/framework/default/ortoo-core/default/lwc/viewAndEditForm/__tests__/viewAndEditForm.test.js +++ b/framework/default/ortoo-core/default/lwc/viewAndEditForm/__tests__/viewAndEditForm.test.js @@ -30,7 +30,7 @@ describe('c-view-and-edit-form', () => { expect( additionalEditButtons ).not.toBe( null ); }); - it('When visible card and not inEditMode, has an edit button, but no save or cancel', () => { + it('When visible card and not inEditMode, has an edit button, but no save or cancel', () => { const element = createElement('c-view-and-edit-form', { is: ViewAndEditForm }); @@ -51,7 +51,7 @@ describe('c-view-and-edit-form', () => { expect( additionalEditButtons ).toBe( null ); }); - it('When visible card and inEditMode, has an editForm slot but no viewForm slot', () => { + it('When visible card and inEditMode, has an editForm, title and info slot but no viewForm slot', () => { const element = createElement('c-view-and-edit-form', { is: ViewAndEditForm }); @@ -64,9 +64,15 @@ describe('c-view-and-edit-form', () => { const editForm = element.shadowRoot.querySelector( 'slot[name="edit-form"]' ); expect( editForm ).not.toBe( null ); - }); - it('When visible card and not inEditMode, has a viewForm slot but no editForm slot', () => { + const title = element.shadowRoot.querySelector( 'slot[name="title"]' ); + expect( title ).not.toBe( null ); + + const info = element.shadowRoot.querySelector( 'slot[name="info"]' ); + expect( info ).not.toBe( null ); + }); + + it('When visible card and not inEditMode, has a viewForm, title and info slot but no editForm slot', () => { const element = createElement('c-view-and-edit-form', { is: ViewAndEditForm }); @@ -79,6 +85,12 @@ describe('c-view-and-edit-form', () => { const editForm = element.shadowRoot.querySelector( 'slot[name="edit-form"]' ); expect( editForm ).toBe( null ); + + const title = element.shadowRoot.querySelector( 'slot[name="title"]' ); + expect( title ).not.toBe( null ); + + const info = element.shadowRoot.querySelector( 'slot[name="info"]' ); + expect( info ).not.toBe( null ); }); it('When visible card, and inEditMode, clicking save will issue a save event', () => { @@ -144,6 +156,21 @@ describe('c-view-and-edit-form', () => { }) }); + it('When visible card, inEditMode, and save and cancel button labels specified, will display those labels', () => { + const element = createElement('c-view-and-edit-form', { + is: ViewAndEditForm + }); + element.visible = true; + element.inEditMode = true; + element.saveLabel = 'overridden-save'; + element.cancelLabel = 'overridden-cancel'; + document.body.appendChild(element); + + const saveButtons = element.shadowRoot.querySelector( 'c-save-buttons' ); + expect( saveButtons.saveLabel ).toBe( 'overridden-save' ); + expect( saveButtons.cancelLabel ).toBe( 'overridden-cancel' ); + }); + it('When visible modal and inEditMode, has a save and cancel button, but no edit', () => { const element = createElement('c-view-and-edit-form', { is: ViewAndEditForm @@ -188,7 +215,7 @@ describe('c-view-and-edit-form', () => { expect( additionalEditButtons ).toBe( null ); }); - it('When visible modal and inEditMode, has an editForm slot but no viewForm slot', () => { + it('When visible modal and inEditMode, has an editForm, title and info slot but no viewForm slot', () => { const element = createElement('c-view-and-edit-form', { is: ViewAndEditForm }); @@ -202,9 +229,15 @@ describe('c-view-and-edit-form', () => { const editForm = element.shadowRoot.querySelector( 'slot[name="edit-form"]' ); expect( editForm ).not.toBe( null ); - }); - it('When visible modal and not inEditMode, has a viewForm slot but no editForm slot', () => { + const title = element.shadowRoot.querySelector( 'slot[name="title"]' ); + expect( title ).not.toBe( null ); + + const info = element.shadowRoot.querySelector( 'slot[name="info"]' ); + expect( info ).not.toBe( null ); + }); + + it('When visible modal and not inEditMode, has a viewForm, title and info slot but no editForm slot', () => { const element = createElement('c-view-and-edit-form', { is: ViewAndEditForm }); @@ -218,6 +251,12 @@ describe('c-view-and-edit-form', () => { const editForm = element.shadowRoot.querySelector( 'slot[name="edit-form"]' ); expect( editForm ).toBe( null ); + + const title = element.shadowRoot.querySelector( 'slot[name="title"]' ); + expect( title ).not.toBe( null ); + + const info = element.shadowRoot.querySelector( 'slot[name="info"]' ); + expect( info ).not.toBe( null ); }); it('When visible modal, and inEditMode, clicking save will issue a save event', () => { @@ -286,6 +325,22 @@ describe('c-view-and-edit-form', () => { }) }); + it('When visible modal, inEditMode, and save and cancel button labels specified, will display those labels', () => { + const element = createElement('c-view-and-edit-form', { + is: ViewAndEditForm + }); + element.mode = 'modal'; + element.visible = true; + element.inEditMode = true; + element.saveLabel = 'overridden-save'; + element.cancelLabel = 'overridden-cancel'; + document.body.appendChild(element); + + const saveButtons = element.shadowRoot.querySelector( 'c-save-buttons' ); + expect( saveButtons.saveLabel ).toBe( 'overridden-save' ); + expect( saveButtons.cancelLabel ).toBe( 'overridden-cancel' ); + }); + it('When configured with an invalid mode, will throw an error', () => { const element = createElement('c-view-and-edit-form', { is: ViewAndEditForm diff --git a/framework/default/ortoo-core/default/lwc/viewAndEditForm/viewAndEditForm-card.html b/framework/default/ortoo-core/default/lwc/viewAndEditForm/viewAndEditForm-card.html index da40073d40f..3ae29ce150d 100644 --- a/framework/default/ortoo-core/default/lwc/viewAndEditForm/viewAndEditForm-card.html +++ b/framework/default/ortoo-core/default/lwc/viewAndEditForm/viewAndEditForm-card.html @@ -21,6 +21,7 @@
+
@@ -32,7 +33,9 @@
- + + +
@@ -40,7 +43,10 @@
diff --git a/framework/default/ortoo-core/default/lwc/viewAndEditForm/viewAndEditForm-modal.html b/framework/default/ortoo-core/default/lwc/viewAndEditForm/viewAndEditForm-modal.html index 0eedf3e2f73..311742510fa 100644 --- a/framework/default/ortoo-core/default/lwc/viewAndEditForm/viewAndEditForm-modal.html +++ b/framework/default/ortoo-core/default/lwc/viewAndEditForm/viewAndEditForm-modal.html @@ -26,6 +26,9 @@
+ + + @@ -37,7 +40,10 @@
- diff --git a/framework/default/ortoo-core/default/lwc/viewAndEditForm/viewAndEditForm.js b/framework/default/ortoo-core/default/lwc/viewAndEditForm/viewAndEditForm.js index e44e54aff9c..45bff4b9196 100644 --- a/framework/default/ortoo-core/default/lwc/viewAndEditForm/viewAndEditForm.js +++ b/framework/default/ortoo-core/default/lwc/viewAndEditForm/viewAndEditForm.js @@ -46,7 +46,8 @@ export default class ViewAndEditForm extends LightningElement { ortooIdConfiguration = { modalId: '', - editId: 'edit' + editId: 'edit', + saveButtonsId: 'savebuttons', } connectedCallback() { diff --git a/framework/default/sobject-fabricator/classes/ortoo_FabricatedSObjectRegister.cls b/framework/default/sobject-fabricator/classes/ortoo_FabricatedSObjectRegister.cls index d282b9495d6..69393da22f0 100644 --- a/framework/default/sobject-fabricator/classes/ortoo_FabricatedSObjectRegister.cls +++ b/framework/default/sobject-fabricator/classes/ortoo_FabricatedSObjectRegister.cls @@ -201,4 +201,4 @@ public class ortoo_FabricatedSObjectRegister { SobjectField relationshipField = new sfab_ObjectDescriber().getFieldForChildRelationship( parent.getSobjectName(), relationship ); return new Relationship( child, relationshipField, parent ); } -} +} \ No newline at end of file