├── .gitignore ├── .forceignore ├── images ├── CharacterCounterEdit.JPG ├── CharacterCounterView.JPG ├── btn-install-unlocked-package-sandbox.png └── btn-install-unlocked-package-production.png ├── force-app └── main │ └── default │ ├── classes │ ├── MockIdBuilder.cls-meta.xml │ ├── UniversalMocker.cls-meta.xml │ ├── Character_Counting_Component_Test.cls-meta.xml │ ├── Character_Counting_Component_Application.cls-meta.xml │ ├── Character_Counting_Component_Controller.cls-meta.xml │ ├── Character_Counting_Component_Service.cls-meta.xml │ ├── MockIdBuilder.cls │ ├── Character_Counting_Component_Controller.cls │ ├── Character_Counting_Component_Application.cls │ ├── Character_Counting_Component_Test.cls │ ├── Character_Counting_Component_Service.cls │ └── UniversalMocker.cls │ ├── staticresources │ ├── character_counter_css.resource-meta.xml │ └── character_counter_css.resource │ ├── lwc │ ├── character_counter_record_view_form │ │ ├── character_counter_record_view_form.js-meta.xml │ │ ├── character_counter_record_view_form.html │ │ └── character_counter_record_view_form.js │ ├── character_counter_record_edit_component │ │ ├── character_counter_record_edit_component.js-meta.xml │ │ ├── character_counter_record_edit_component.html │ │ └── character_counter_record_edit_component.js │ └── character_counting_component │ │ ├── character_counting_component.html │ │ ├── character_counting_component.js-meta.xml │ │ └── character_counting_component.js │ └── objects │ └── Account.object ├── sfdx-project.json ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .sfdx/ 2 | **/jsconfig.json -------------------------------------------------------------------------------- /.forceignore: -------------------------------------------------------------------------------- 1 | **/jsconfig.json 2 | 3 | **/.eslintrc.json 4 | -------------------------------------------------------------------------------- /images/CharacterCounterEdit.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coding-With-The-Force/Salesforce-Character-Counting-Layout-Component/HEAD/images/CharacterCounterEdit.JPG -------------------------------------------------------------------------------- /images/CharacterCounterView.JPG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coding-With-The-Force/Salesforce-Character-Counting-Layout-Component/HEAD/images/CharacterCounterView.JPG -------------------------------------------------------------------------------- /images/btn-install-unlocked-package-sandbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coding-With-The-Force/Salesforce-Character-Counting-Layout-Component/HEAD/images/btn-install-unlocked-package-sandbox.png -------------------------------------------------------------------------------- /images/btn-install-unlocked-package-production.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Coding-With-The-Force/Salesforce-Character-Counting-Layout-Component/HEAD/images/btn-install-unlocked-package-production.png -------------------------------------------------------------------------------- /force-app/main/default/classes/MockIdBuilder.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 50.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/UniversalMocker.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 50.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/Character_Counting_Component_Test.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 53.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/Character_Counting_Component_Application.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 53.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/Character_Counting_Component_Controller.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 53.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/classes/Character_Counting_Component_Service.cls-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 53.0 4 | Active 5 | 6 | -------------------------------------------------------------------------------- /force-app/main/default/staticresources/character_counter_css.resource-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Public 4 | text/css 5 | This is the css for the character counter component 6 | 7 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/character_counter_record_view_form/character_counter_record_view_form.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 53.0 4 | The record view portion of the character counting component 5 | false 6 | Character Counter Record View Form 7 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/character_counter_record_edit_component/character_counter_record_edit_component.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 53.0 4 | This is the edit portion of the character counting component. 5 | false 6 | Character Counter Record Edit Component 7 | -------------------------------------------------------------------------------- /force-app/main/default/classes/MockIdBuilder.cls: -------------------------------------------------------------------------------- 1 | @isTest 2 | public with sharing class MockIdBuilder { 3 | 4 | public static Integer idCount = 0; 5 | 6 | //Get a fake id for a given SObjectType 7 | public static String getMockId(SObjectType objectType) { 8 | 9 | String nextIdCount = String.valueOf(idCount++); 10 | 11 | return objectType.getDescribe().getKeyPrefix() 12 | + getFillerZeros(nextIdCount) 13 | + nextIdCount; 14 | } 15 | 16 | //Gets how many 0's the id needs for correct length 17 | private static String getFillerZeros(String nextIdCount) { 18 | return '0'.repeat(12-nextIdCount.length()); 19 | } 20 | } -------------------------------------------------------------------------------- /sfdx-project.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageDirectories": [ 3 | { 4 | "path": "force-app", 5 | "default": true, 6 | "package": "Character Counter Component", 7 | "versionName": "ver 0.1", 8 | "versionNumber": "0.1.0.NEXT" 9 | } 10 | ], 11 | "name": "The_Character_Counting_Component", 12 | "namespace": "", 13 | "sfdcLoginUrl": "https://login.salesforce.com", 14 | "sourceApiVersion": "54.0", 15 | "packageAliases": { 16 | "Character Counter Component": "0Ho4R0000004CEOSA2", 17 | "Character Counter Component@0.1.0-1": "04t4R000001hhzVQAQ", 18 | "Character Counter Component@0.1.0-2": "04t4R000001hhzaQAA", 19 | "Character Counter Component@0.1.0-3": "04t4R000001hhzkQAA" 20 | } 21 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Coding With The Force 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /force-app/main/default/objects/Account.object: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | ApexTestFieldSet 5 | This is only used for apex testing for the character counting component. This can be deleted once you deploy to your org, create at least one field set for the component and update the Character_Counting_Component_Test test class. 6 | 7 | Name 8 | false 9 | false 10 | 11 | 12 | BillingCity 13 | false 14 | false 15 | 16 | 17 | Description 18 | false 19 | false 20 | 21 | 22 | AccountSource 23 | false 24 | false 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/character_counter_record_view_form/character_counter_record_view_form.html: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /force-app/main/default/classes/Character_Counting_Component_Controller.cls: -------------------------------------------------------------------------------- 1 | /* 2 | @description: This controller class is used by the character_counting_component to retrieve data from the Character_Counting_Component_Service 3 | Apex Class to allow the lwc to function as expected (displaying data and locking down fields and forms as expected). All real logic resides in the 4 | service class, comments on each methods purpose can be found within the service class. 5 | 6 | @author: Matt Gerry (codingwiththeforce@gmail.com) 7 | 8 | @date: 5/3/2022 9 | */ 10 | 11 | public with sharing class Character_Counting_Component_Controller 12 | { 13 | private static Character_Counting_Component_Service countingService = 14 | (Character_Counting_Component_Service)Character_Counting_Component_Application.service.newInstance(Character_Counting_Component_Service.class); 15 | 16 | @AuraEnabled 17 | public static List getFieldsToDisplay(String fieldSetName, String objectApiName, Id recordId, Integer characterWarningThreshold){ 18 | try{ 19 | return countingService.getFieldsToDisplay(fieldSetName, objectApiName, recordId, characterWarningThreshold); 20 | } 21 | catch(Exception ex){ 22 | throw new AuraHandledException(ex.getMessage()); 23 | } 24 | } 25 | 26 | @AuraEnabled 27 | public static Boolean canUserEditRecord(Id recordId){ 28 | try{ 29 | return countingService.canUserEditRecord(recordId); 30 | } 31 | catch(Exception ex){ 32 | throw new AuraHandledException(ex.getMessage()); 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /force-app/main/default/lwc/character_counter_record_edit_component/character_counter_record_edit_component.html: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/character_counter_record_view_form/character_counter_record_view_form.js: -------------------------------------------------------------------------------- 1 | /* 2 | @description: This component is used to display a record view form that displays character counts for text fields. This is embedded 3 | in the character_counting_component lwc, but could also be used independently in other components as well if desired. 4 | 5 | @author: Matt Gerry (codingwiththeforce@gmail.com) 6 | 7 | @date: 5/3/2022 8 | */ 9 | 10 | import {LightningElement, api, track} from 'lwc'; 11 | 12 | export default class CharacterCounterRecordViewForm extends LightningElement { 13 | @api recordId; 14 | @api objectApiName; 15 | @api fieldData; 16 | @api renderEditButton; 17 | @api fieldColumns = 1; 18 | @track fieldDataCopy 19 | CHARACTERS_REMAINING = 'characters remaining'; 20 | columnClasses = 'column-class'; 21 | 22 | connectedCallback() { 23 | //Due to this.fieldData being an api exposed variable, we need to clone it to be able to update it. This is how you clone objects in js. 24 | this.fieldDataCopy = JSON.parse(JSON.stringify(this.fieldData)); 25 | this.determinePageLayout(); 26 | } 27 | 28 | /* 29 | @description: This method is used to setup the correct classes for a two column layout if the user has chosen to display the 30 | component in that fashion. 31 | */ 32 | determinePageLayout(){ 33 | if(this.fieldColumns == 2){ 34 | this.columnClasses = 'column-class slds-col slds-size_6-of-12 slds-p-horizontal_medium slds-float-left inline-grid'; 35 | } 36 | } 37 | 38 | /* 39 | @description: This method is used to dispatch an event to its parent component to inform it that the user has clicked the edit 40 | button and that we should switch to the lightning record edit form. 41 | */ 42 | enableEditing(){ 43 | this.dispatchEvent(new CustomEvent("enableedit")); 44 | } 45 | } -------------------------------------------------------------------------------- /force-app/main/default/staticresources/character_counter_css.resource: -------------------------------------------------------------------------------- 1 | /* 2 | @description: This style sheet was created for the character_counting_component and its subcomponents. It should be 3 | loaded into the character_counter_component lwc for the character counter to display itself properly. All css changes should 4 | ideally be made here to retain consistency between the various views of the component. 5 | 6 | @author: Matt Gerry (codingwiththeforce@gmail.com) 7 | 8 | @date: 5/3/2022 9 | */ 10 | 11 | .characters-remaining, .characters-remaining-red{ 12 | font-size: 10px; 13 | padding: 0.25rem; 14 | } 15 | 16 | .counting-accordion .slds-accordion__section{ 17 | background-color: rgb(255, 255, 255); 18 | } 19 | 20 | .characters-remaining-red{ 21 | color: red; 22 | } 23 | 24 | .field-spacing{ 25 | padding: 5px 0 5px 0; 26 | width: 100%; 27 | flex-basis: 100%; 28 | display: block; 29 | border-bottom: #e5e5e5 1px solid; 30 | } 31 | 32 | .field-spacing .slds-form-element{ 33 | pointer-events: none; 34 | } 35 | 36 | .counting-accordion button.slds-accordion__summary-action{ 37 | background-color: #f3f3f3 !important; 38 | } 39 | 40 | .edit-icon-space{ 41 | float:right; 42 | z-index: 999; 43 | } 44 | 45 | .edit-icon .slds-button__icon{ 46 | fill: #dddbda !important; 47 | cursor: pointer; 48 | } 49 | 50 | .column-class:hover svg{ 51 | fill: gray !important; 52 | } 53 | 54 | .card-background .slds-card__header{ 55 | background-color: #f3f3f3 !important; 56 | padding: 10px !important; 57 | border: #e5e5e5 1px solid !important; 58 | border-radius: 0.25rem; 59 | } 60 | 61 | .card-background .slds-card{ 62 | border: #e5e5e5 1px solid !important; 63 | } 64 | 65 | .card-background .slds-card__body{ 66 | margin: 10px !important; 67 | } 68 | 69 | .inline-grid{ 70 | display: inline-grid; 71 | } 72 | -------------------------------------------------------------------------------- /force-app/main/default/classes/Character_Counting_Component_Application.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by gerry on 5/4/2022. 3 | */ 4 | 5 | public with sharing class Character_Counting_Component_Application 6 | { 7 | //This allows us to create a factory for instantiating service classes. You send it the interface for your service class 8 | //and it will return the correct service layer class 9 | //Exmaple initialization: Object objectService = Application.service.newInstance(Character_Counting_Component_Service.class); 10 | public static final ServiceFactory service = 11 | new ServiceFactory( 12 | new Map{ 13 | Character_Counting_Component_Service.class => Character_Counting_Component_Service.class} 14 | ); 15 | 16 | /** 17 | * Simple Service Factory implementation 18 | **/ 19 | public class ServiceFactory 20 | { 21 | protected Map m_serviceInterfaceTypeByServiceImplType; 22 | 23 | protected Map m_serviceInterfaceTypeByMockService; 24 | 25 | /** 26 | * Constructs a simple Service Factory 27 | **/ 28 | public ServiceFactory() { } 29 | 30 | /** 31 | * Constructs a simple Service Factory, 32 | * using a Map of Apex Interfaces to Apex Classes implementing the interface 33 | * Note that this will not check the Apex Classes given actually implement the interfaces 34 | * as this information is not presently available via the Apex runtime 35 | * 36 | * @param serviceInterfaceTypeByServiceImplType Map ofi interfaces to classes 37 | **/ 38 | public ServiceFactory(Map serviceInterfaceTypeByServiceImplType) 39 | { 40 | m_serviceInterfaceTypeByServiceImplType = serviceInterfaceTypeByServiceImplType; 41 | m_serviceInterfaceTypeByMockService = new Map(); 42 | } 43 | 44 | /** 45 | * Returns a new instance of the Apex class associated with the given Apex interface 46 | * Will return any mock implementation of the interface provided via setMock 47 | * Note that this method will not check the configured Apex class actually implements the interface 48 | * 49 | * @param serviceInterfaceType Apex interface type 50 | * @exception Is thrown if there is no registered Apex class for the interface type 51 | **/ 52 | public virtual Object newInstance(Type serviceInterfaceType) 53 | { 54 | // Mock implementation? 55 | if(m_serviceInterfaceTypeByMockService.containsKey(serviceInterfaceType)) 56 | return m_serviceInterfaceTypeByMockService.get(serviceInterfaceType); 57 | 58 | // Create an instance of the type implementing the given interface 59 | Type serviceImpl = m_serviceInterfaceTypeByServiceImplType.get(serviceInterfaceType); 60 | if(serviceImpl==null) 61 | throw new DeveloperException('No implementation registered for service interface ' + serviceInterfaceType.getName()); 62 | return serviceImpl.newInstance(); 63 | } 64 | 65 | @TestVisible 66 | private virtual void setMock(Type serviceInterfaceType, Object serviceImpl) 67 | { 68 | m_serviceInterfaceTypeByMockService.put(serviceInterfaceType, serviceImpl); 69 | } 70 | } 71 | 72 | /** 73 | * Exception representing a developer coding error, not intended for end user eyes 74 | **/ 75 | public class DeveloperException extends Exception { } 76 | } -------------------------------------------------------------------------------- /force-app/main/default/lwc/character_counting_component/character_counting_component.html: -------------------------------------------------------------------------------- 1 | 11 | 12 | -------------------------------------------------------------------------------- /force-app/main/default/classes/Character_Counting_Component_Test.cls: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by gerry on 5/3/2022. 3 | */ 4 | //ApexTestFieldSet 5 | @IsTest 6 | public with sharing class Character_Counting_Component_Test 7 | { 8 | private static final String CAN_USER_EDIT_RECORD_METHOD = 'canUserEditRecord'; 9 | private static final String TEST_FIELD_SET = 'ApexTestFieldSet'; 10 | private static final String ACCOUNT_OBJECT = 'Account'; 11 | private static final Integer WARNING_THRESHOLD = 200; 12 | 13 | @IsTest 14 | private static void canUserEditRecord_UserCanEditRecord_UnitTest(){ 15 | Test.startTest(); 16 | Boolean canUserEdit = Character_Counting_Component_Controller.canUserEditRecord(createAccount().Id); 17 | Test.stopTest(); 18 | System.assertEquals(true, canUserEdit, 'For some reason admins cant edit records. Somethings not right'); 19 | } 20 | 21 | @IsTest 22 | private static void canUserEditRecord_QueryException_UnitTest(){ 23 | Id mockAccountId = MockIdBuilder.getMockId(Account.SObjectType); 24 | UniversalMocker characterCountingServiceMockPrep = UniversalMocker.mock(Character_Counting_Component_Service.class); 25 | characterCountingServiceMockPrep.when(CAN_USER_EDIT_RECORD_METHOD).withParamTypes(new List{Id.class}).thenThrow(new QueryException()); 26 | Character_Counting_Component_Service mockService = (Character_Counting_Component_Service)characterCountingServiceMockPrep.createStub(); 27 | Character_Counting_Component_Application.service.setMock(Character_Counting_Component_Service.class, mockService); 28 | try{ 29 | Test.startTest(); 30 | Character_Counting_Component_Controller.canUserEditRecord(mockAccountId); 31 | Test.stopTest(); 32 | } 33 | catch(Exception ex){ 34 | System.assert(ex instanceof AuraHandledException); 35 | } 36 | } 37 | 38 | @IsTest 39 | private static void getFieldsToDisplay_RetrieveFieldsForRecordEdit_IntTest(){ 40 | Test.startTest(); 41 | List fieldInfo = Character_Counting_Component_Controller.getFieldsToDisplay(TEST_FIELD_SET, ACCOUNT_OBJECT, createAccount().Id, WARNING_THRESHOLD); 42 | Test.stopTest(); 43 | System.assertEquals(4, fieldInfo.size(), 'The field info records dont match the size of the field set'); 44 | } 45 | 46 | @IsTest 47 | private static void getFieldsToDisplay_RetrieveFieldsForNewRecord_IntTest(){ 48 | Test.startTest(); 49 | List fieldInfo = Character_Counting_Component_Controller.getFieldsToDisplay(TEST_FIELD_SET, ACCOUNT_OBJECT, null, WARNING_THRESHOLD); 50 | Test.stopTest(); 51 | System.assertEquals(4, fieldInfo.size(), 'The field info records dont match the size of the field set'); 52 | } 53 | 54 | @IsTest 55 | private static void getFieldsToDisplay_QueryForFieldDataCountingException_UnitTest(){ 56 | Id mockAccountId = MockIdBuilder.getMockId(Account.SObjectType); 57 | try{ 58 | Test.startTest(); 59 | Character_Counting_Component_Controller.getFieldsToDisplay(TEST_FIELD_SET, ACCOUNT_OBJECT, mockAccountId, WARNING_THRESHOLD); 60 | Test.stopTest(); 61 | } 62 | catch(Exception ex){ 63 | System.assert(ex instanceof AuraHandledException); 64 | } 65 | } 66 | 67 | private static Account createAccount(){ 68 | Account acct = new Account(Name = 'Test Account', BillingCity = ' Test City'); 69 | insert acct; 70 | return acct; 71 | } 72 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The Salesforce Character Counting Layout Component 2 |

3 | 4 | Deploy to Salesforce 6 | 7 |
8 | 9 | Deploy Unlocked Package to Prod 10 | 11 | 12 | Deploy Unlocked Package to Prod 13 | 14 |

15 | 16 | 17 | Ever wished you could see how many characters were left when entering text into a 18 | text field in Salesforce?? Well this is the answer! It works on every field in every 19 | field combination and it's completely driven by field sets! No custom code ever again! 20 | 21 | Click the images below to check out what this component can look and feel like! Remember though, there are tons of config options, so you can change this up quite a bit! 22 | 23 | The Character Counter's View Two-Column Layout Example 24 | ![Character Counting View Display](https://github.com/Coding-With-The-Force/Salesforce_Character_Counting_Component/blob/master/images/CharacterCounterView.JPG?raw=true) 25 | 26 | The Character Counter's Edit Two-Column Layout Example 27 | ![Character Counting View Display](https://github.com/Coding-With-The-Force/Salesforce_Character_Counting_Component/blob/master/images/CharacterCounterEdit.JPG?raw=true) 28 | 29 | --- 30 | # Features 31 | 1. An abstract 100% configuration based component that is driven by field sets. 32 | 2. It can work in virtually any situation! Embed it in your lightning record pages, use it in a custom new record page, put it in a flow or merge it into another custom component of yours. It can adapt to any situation. 33 | 3. It can be used on literally any object with any combination of fields! 34 | 4. Supports character counting on all text based fields! Even rich text! 35 | 5. Has the ability to display in a single column or a double column layout. 36 | 6. Has the ability to present itself as a stand-alone component or to present itself as a field section in a page layout to integrate smooth into page layouts. 37 | 7. You can set your own header title and optionally your own icon for the header 38 | 8. Can be used on lightning record pages 39 | 9. Can be used in flows 40 | 10. Can be used in custom new record pages 41 | 11. Can be merged into larger lwc, aura or vf components seamlessly 42 | 43 | --- 44 | # Installation 45 | 46 | You can currently install the component via any of the three links below. I would personally suggest leveraging the unlocked packaging options as it will allow you to easily keep up with updates for the component and keep it self-contained. 47 | 48 | However if you are adverse to unlocked packaging (for some crazy reason) you can use the "Deploy to Salesforce" button which will deploy the code, without the packaging, to your organization. 49 | 50 | 51 |

Deploy to your org without packaging using the link below:

52 | 53 | Deploy to Salesforce 55 | 56 |
57 | 58 |

Deploy to your org with packaging using one of the links below (HIGHLY SUGGESTED!!):

59 | 60 | Deploy Unlocked Package to Prod 61 | 62 | 63 | Deploy Unlocked Package to Prod 64 | 65 | 66 | --- 67 | 68 | # Setup and Installation Tutorial Video 69 | 70 | [![Introduction to the Separation of Concerns Design Principle](https://yt-embed.herokuapp.com/embed?v=_zci5Ug_848)](https://www.youtube.com/watch?v=_zci5Ug_848 "How to Setup and Install The Character Counting Component") 71 | 72 | 73 | 74 | 75 | # Suggestions For Developers Setting Up This Component 76 | 77 | While this component has fairly robust exception handling it does not have logging and it also does not leverage selectors. I chose to do this because I didn't want to load this project with dependencies that you may not want to use yourself and creating a logger or a selector layer solution is not the point of this repo/component. I would suggest that you add error logging and a selector layer that you are comfortable with to this code prior to leveraging it. 78 | 79 | # Notes For Contributors 80 | 81 | Temporarily, while I get my jest tests finalized and ci/cd setup. Please submit pull requests to the integration branch. It helps me test a bit easier. I will hopefully have jest and ci/cd setup in this branch within the month. 82 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/character_counter_record_edit_component/character_counter_record_edit_component.js: -------------------------------------------------------------------------------- 1 | /* 2 | @description: This component is used to display a record edit form that displays character counts for text fields. This is embedded 3 | in the character_counting_component lwc, but could also be used independently in other components as well if desired. 4 | 5 | @author: Matt Gerry (codingwiththeforce@gmail.com) 6 | 7 | @date: 5/3/2022 8 | */ 9 | 10 | import {LightningElement, api, track} from 'lwc'; 11 | import {NavigationMixin} from "lightning/navigation"; 12 | 13 | export default class CharacterCounterRecordEditComponent extends NavigationMixin(LightningElement) { 14 | @api recordId; 15 | @api objectApiName; 16 | @api fieldData; 17 | @api renderSaveButton; 18 | @api characterWarningThreshold; 19 | @api fieldColumns = 1; 20 | @track fieldDataCopy; 21 | columnClasses = ''; 22 | 23 | connectedCallback() { 24 | // Due to this.fieldData being an api exposed variable, we need to clone it to be able to update it. This is how you clone objects in js. 25 | this.fieldDataCopy = JSON.parse(JSON.stringify(this.fieldData)).map(fieldData => this.addFieldSpecificStyling(fieldData)); 26 | this.determinePageLayout(); 27 | } 28 | 29 | /* 30 | @description: This method is used to setup the correct classes for a two column layout if the user has chosen to display the 31 | component in that fashion. 32 | */ 33 | determinePageLayout(){ 34 | if(this.fieldColumns == 2){ 35 | this.columnClasses = 'slds-col slds-size_6-of-12 slds-var-p-horizontal_medium slds-float-left inline-grid'; 36 | } 37 | } 38 | 39 | /* 40 | @description: This method is called by the onchange event within each lightning input field that is of type string to allow it to count 41 | down the characters remaining appropriately. 42 | */ 43 | determineCharactersLeft(event){ 44 | let fieldValue = event.detail.value; 45 | let fieldName = event.target.fieldName; 46 | for(let field of this.fieldDataCopy){ 47 | if(field.fieldApiName === fieldName){ 48 | field.currentLength = fieldValue.length; 49 | field.charactersRemaining = field.stringFieldLength - field.currentLength; 50 | this.checkFieldConstraints(field); 51 | this.addFieldSpecificStyling(field); 52 | } 53 | } 54 | } 55 | 56 | /* 57 | @description: This method is used to determine whether we should show the red warning text in the character counter and whether 58 | or not we should prevent further input into the field if we are out of characters 59 | */ 60 | checkFieldConstraints(field){ 61 | if(field.charactersRemaining <= this.characterWarningThreshold){ 62 | field.belowCharsThreshold = true; 63 | } 64 | else{ 65 | field.belowCharsThreshold = false; 66 | } 67 | if(field.noCharsLeft === 0){ 68 | field.noCharsLeft = true; 69 | } 70 | else{ 71 | field.noCharsLeft = false; 72 | } 73 | } 74 | 75 | /* 76 | @description: This method is called by an onsubmit event from the lightning record edit form and it only updates field data if we have 77 | a recordId populated in the component. If we don't have a record id, then this is a record creation even and record creation events are 78 | handled in the handleSaveSuccess method below. 79 | */ 80 | @api saveData(event){ 81 | event.preventDefault(); 82 | this.template.querySelector('lightning-record-edit-form').submit(event.detail.fields); 83 | if(this.recordId) { 84 | this.updateFieldData(); 85 | } 86 | } 87 | 88 | /* 89 | @description: This method is called by an onsuccess event handler on the lightning record edit form and only navigates 90 | to a new record page if we have actually created a record (via a new record page setup). 91 | We currently determine whether we are creating a new record based upon whether or not a recordId was passed into this component or not. 92 | */ 93 | handleSaveSuccess(event){ 94 | if(!this.recordId){ 95 | this.navigateToNewRecordPage(event.detail.id); 96 | } 97 | } 98 | 99 | navigateToNewRecordPage(recordId){ 100 | this[NavigationMixin.Navigate]({ 101 | type: 'standard__recordPage', 102 | attributes: { 103 | recordId: recordId, 104 | objectApiName: this.objectApiName, 105 | actionName: 'view' 106 | } 107 | }); 108 | } 109 | 110 | /* 111 | @description: This method is used to dispatch an event to its parent component to inform it that the user has clicked the save 112 | button and that we should switch to the lightning record view form. 113 | */ 114 | disableEditing(){ 115 | this.dispatchEvent(new CustomEvent('disableedit')); 116 | } 117 | 118 | /* 119 | @description: This event is necessary to keep the parent and view component up to date with the changes that 120 | were enacted in this edit component. 121 | */ 122 | updateFieldData(){ 123 | this.dispatchEvent(new CustomEvent('updatefielddata', { 124 | detail:{ 125 | fielddata: this.fieldDataCopy 126 | } 127 | })); 128 | } 129 | 130 | /* 131 | @description: Does bookkeeping on field-related styles/counter text 132 | */ 133 | addFieldSpecificStyling(fieldData) { 134 | let inputStyle = ''; 135 | let paragraphStyle = ''; 136 | if (fieldData.isString) { 137 | inputStyle = 'character-counter'; 138 | paragraphStyle = `characters-remaining${fieldData.belowCharsThreshold ? '-red' : ''}`; 139 | fieldData.paragraphText = `${fieldData.charactersRemaining} characters remaining out of ${fieldData.stringFieldLength}` 140 | if (fieldData.noCharsLeft) { 141 | fieldData.disabled = true; 142 | } else if (fieldData.disabled) { 143 | delete fieldData.disabled; 144 | } 145 | } 146 | fieldData.inputStyle = inputStyle; 147 | fieldData.paragraphStyle = paragraphStyle; 148 | fieldData.paragraphKey = fieldData.fieldApiName + 'paragraph' 149 | return fieldData 150 | } 151 | } -------------------------------------------------------------------------------- /force-app/main/default/lwc/character_counting_component/character_counting_component.js-meta.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 53.0 4 | This component allows you to show the remaining characters in your text fields 5 | true 6 | Character Counting Component 7 | 8 | lightning__RecordPage 9 | lightning__FlowScreen 10 | 11 | 12 | 13 | 15 | 17 | 20 | 23 | 25 | 27 | 29 | 31 | 33 | 34 | 35 | 37 | 39 | 41 | 43 | 46 | 49 | 51 | 53 | 55 | 57 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /force-app/main/default/lwc/character_counting_component/character_counting_component.js: -------------------------------------------------------------------------------- 1 | /* 2 | @description: This component is the parent component that is used to display the character counting component. Its purpose is to facilitate 3 | communication between its subcomponents and the user entered settings to display the character counter as desired. This component is the component 4 | that will actually be placed within your lightning app builder page, new record page, flow, etc. Its variables are exposed and available to both 5 | flows and lightning app builder record pages 6 | 7 | @author: Matt Gerry (codingwiththeforce@gmail.com) 8 | 9 | @date: 5/3/2022 10 | */ 11 | 12 | import {LightningElement, api, track} from 'lwc'; 13 | import {loadStyle} from "lightning/platformResourceLoader"; 14 | import {NavigationMixin} from "lightning/navigation"; 15 | import characterCountingComponentStyle from '@salesforce/resourceUrl/character_counter_css'; 16 | import getFieldsToDisplayController from '@salesforce/apex/Character_Counting_Component_Controller.getFieldsToDisplay'; 17 | import canUserEditRecordController from '@salesforce/apex/Character_Counting_Component_Controller.canUserEditRecord'; 18 | import {ShowToastEvent} from "lightning/platformShowToastEvent"; 19 | 20 | 21 | export default class CharacterCountingComponent extends NavigationMixin(LightningElement) { 22 | @api recordId; 23 | @api objectApiName; 24 | @api sectionHeader; 25 | @api fieldSetName; 26 | @api renderEditButton = false; 27 | @api renderSaveButton = false; 28 | @api displayAsFieldSection = false; 29 | @api displayAsIndependentSection = false; 30 | @api fieldColumns = 1; 31 | @api characterWarningThreshold = 25; 32 | @api iconName = ""; 33 | 34 | @track fieldData; 35 | @track activeSections = []; 36 | 37 | errorMsg; 38 | userEditing = false; 39 | dataRetrieved = false; 40 | userCreatingRecord = false; 41 | 42 | /* 43 | @description: This method is exposed so that if embedded into a parent component the parent component can call the edit forms save 44 | functionality. 45 | */ 46 | @api saveData(){ 47 | this.template.querySelector('c-character_counter_record_edit_component').saveData(); 48 | } 49 | 50 | async connectedCallback() { 51 | await this.prepComponent(); 52 | } 53 | 54 | /* 55 | @description: This method is used to setup the component to ensure it is styled appropriately, security is upheld and that the correct 56 | data is retrieved from the system to be displayed. 57 | */ 58 | async prepComponent(){ 59 | await loadStyle(this, characterCountingComponentStyle); 60 | this.setActiveSections(); 61 | this.determineIfNewRecordOrEditingRecord(); 62 | this.getFieldsToDisplay(); 63 | } 64 | 65 | /* 66 | @description: If we have an id, we are updating a record so we need to check security. Otherwise we're on a new record form 67 | and should immediately flip to display a record edit form. 68 | */ 69 | determineIfNewRecordOrEditingRecord(){ 70 | if(this.recordId) { 71 | this.canUserEditRecord(); 72 | } 73 | else{ 74 | this.userCreatingRecord = true; 75 | } 76 | } 77 | 78 | /* 79 | @description: Sets the active section for the accordion element. 80 | */ 81 | setActiveSections(){ 82 | this.activeSections = [this.sectionHeader]; 83 | } 84 | 85 | /* 86 | @description: This method calls to the apex controller to ensure that a user has rights to edit a record before allowing them to do it. 87 | */ 88 | canUserEditRecord(){ 89 | canUserEditRecordController({recordId: this.recordId}).then(canUserEdit =>{ 90 | if(canUserEdit === false && this.renderEditButton === true) { 91 | this.renderEditButton = canUserEdit; 92 | } 93 | }).catch(error =>{ 94 | this.displayErrors(error); 95 | }); 96 | } 97 | 98 | /* 99 | @description: This method calls to the controller to retrieve the field data from our field set and current record 100 | to display to our user. 101 | */ 102 | getFieldsToDisplay(){ 103 | getFieldsToDisplayController({fieldSetName: this.fieldSetName, objectApiName: this.objectApiName, 104 | recordId: this.recordId, characterWarningThreshold: this.characterWarningThreshold}).then(fieldInfo =>{ 105 | this.fieldData = fieldInfo; 106 | this.dataRetrieved = true; 107 | }).catch(error =>{ 108 | this.displayErrors(error); 109 | }) 110 | } 111 | 112 | /* 113 | @description: Used to render the record edit form 114 | */ 115 | @api enableEditing(){ 116 | this.userEditing = true; 117 | } 118 | 119 | /* 120 | @description: Used to un-render the record edit form. When there is a record id present we are editing an existing record 121 | so we just need to move back to the record view form. If there is no record id then we are creating a new record and need 122 | to navigate back to the objects home page instead. 123 | */ 124 | @api disableEditing(){ 125 | if(this.recordId) { 126 | this.userEditing = false; 127 | } 128 | else{ 129 | this.navigateToObjectHomePage(); 130 | } 131 | } 132 | 133 | navigateToObjectHomePage(){ 134 | this[NavigationMixin.Navigate]({ 135 | type: 'standard__objectPage', 136 | attributes: { 137 | objectApiName: this.objectApiName, 138 | actionName: 'home' 139 | } 140 | }); 141 | } 142 | 143 | /* 144 | @description: Called from the record edit forms onupdatefielddata event to ensure data is consistent between components 145 | after being edited 146 | */ 147 | @api updateFieldData(event){ 148 | this.fieldData = event.detail.fielddata; 149 | this.disableEditing(); 150 | } 151 | 152 | /* 153 | @description: Used to prep and display error toasts in catch blocks 154 | */ 155 | displayErrors(error){ 156 | this.handleErrors(error); 157 | this.dispatchEvent(this.showToast('Error', this.errorMsg, 'error', 'sticky')); 158 | } 159 | 160 | /* 161 | @description: Properly parses aura handled exceptions for display to the user in a toast. 162 | */ 163 | handleErrors(err){ 164 | if (Array.isArray(err.body)) { 165 | this.errorMsg = err.body.map(e => e.message).join(', '); 166 | } else if (typeof err.body.message === 'string') { 167 | this.errorMsg = err.body.message; 168 | } 169 | } 170 | 171 | /* 172 | @description: Used for toast displays 173 | */ 174 | showToast(toastTitle, toastMessage, toastVariant, toastMode) { 175 | const evt = new ShowToastEvent({ 176 | title: toastTitle, 177 | message: toastMessage, 178 | variant: toastVariant, 179 | mode: toastMode 180 | }); 181 | return evt; 182 | } 183 | 184 | /* 185 | @description: Used to render the view form template 186 | */ 187 | get notEditingAndDataRetrieved(){ 188 | if(this.userEditing === false && this.userCreatingRecord === false && this.dataRetrieved === true){ 189 | return true; 190 | } 191 | } 192 | 193 | /* 194 | @description: Used to render the edit form template 195 | */ 196 | get userEditingOrCreating(){ 197 | if((this.userEditing === true || this.userCreatingRecord === true) && this.dataRetrieved === true){ 198 | return true; 199 | } 200 | } 201 | } -------------------------------------------------------------------------------- /force-app/main/default/classes/Character_Counting_Component_Service.cls: -------------------------------------------------------------------------------- 1 | /* 2 | @description: This service class serves to provide the data necessary to run the character_counting_component lwc. 3 | Its primary purpose is to retrieve field set data, metadata about that fields and data in those fields for the current record 4 | and merge that info into a list of our FieldData wrapper inner class. This class also provides a way to check if a user has 5 | access to edit the record they are operating on. 6 | 7 | @author: Matt Gerry (codingwiththeforce@gmail.com) 8 | 9 | @date: 5/3/2022 10 | */ 11 | 12 | public with sharing class Character_Counting_Component_Service 13 | { 14 | private final String STRING_TEXT = 'STRING'; 15 | private final String TEXT_AREA_TEXT = 'TEXTAREA'; 16 | private DescribeSObjectResult[] describes; 17 | 18 | /* 19 | @description: This method is used to determine whether a user has edit access to the record they are currently viewing. 20 | */ 21 | public Boolean canUserEditRecord(Id recordId){ 22 | try{ 23 | return [SELECT RecordId, HasEditAccess FROM UserRecordAccess WHERE 24 | UserId = :UserInfo.getUserId() AND RecordId = :recordId LIMIT 1].HasEditAccess; 25 | } 26 | catch(QueryException qe){ 27 | throw new CharacterCountingException('There was an issue determining your access to the record. ' + 28 | 'Please ensure the component has been setup appropriately and that your user has access to the' + 29 | 'object you\'re viewing. This was the system generated issue: ' + qe.getMessage()); 30 | } 31 | } 32 | 33 | /* 34 | @description: This method is called to retrieve our list of FieldData to display in our component in both the view and edit forms. 35 | If we have a record id the user is currently viewing a record we intend to edit, otherwise we are creating a new record. 36 | */ 37 | public List getFieldsToDisplay(String fieldSetName, String objectApiName, Id recordId, Integer characterWarningThreshold){ 38 | describes = Schema.describeSObjects(new String[]{objectApiName}); 39 | if(recordId != null){ 40 | return getRecordData(getFieldSetFields(fieldSetName), recordId, characterWarningThreshold); 41 | } 42 | else{ 43 | return createFieldDataWithNoExistingRecord(getFieldSetFields(fieldSetName)); 44 | } 45 | } 46 | 47 | /* 48 | @description: This method is used to setup FieldData to display in a lightning record edit form for new record layouts 49 | */ 50 | private List createFieldDataWithNoExistingRecord(List fieldSetFields){ 51 | List fieldDataList = new List(); 52 | Map fieldMap = describes[0].fields.getMap(); 53 | for(Schema.FieldSetMember field: fieldSetFields){ 54 | FieldData fieldInfo = new FieldData(); 55 | fieldInfo.fieldApiName = field.getFieldPath(); 56 | if(field.getType().name() == STRING_TEXT || field.getType().name() == TEXT_AREA_TEXT){ 57 | SObjectField fieldMetadata = fieldMap.get(field.getFieldPath()); 58 | fieldInfo.stringFieldLength = fieldMetadata.getDescribe().getLength(); 59 | fieldInfo.charactersRemaining = fieldInfo.stringFieldLength; 60 | fieldInfo.isString = true; 61 | } 62 | fieldDataList.add(fieldInfo); 63 | } 64 | return fieldDataList; 65 | } 66 | 67 | /* 68 | @description: This method is used to retrieve our field sets fields so that we can collect more information from them from both 69 | the current record we are editing (potentially) and the metadata of that field (like field length). 70 | */ 71 | private List getFieldSetFields(String fieldSetName) 72 | { 73 | if (!describes.isEmpty()) { 74 | try{ 75 | return describes[0].fieldSets.getMap().get(fieldSetName).fields; 76 | } 77 | catch(Exception ex){ 78 | throw new CharacterCountingException('The field set you entered for this object does not exist! ' + 79 | 'Please check the name of the field set you passed into the component!'); 80 | } 81 | } 82 | else{ 83 | throw new CharacterCountingException('The object api name you passed into the component is invalid! ' + 84 | 'Please check the name of the object api name you passed into the component!'); 85 | } 86 | } 87 | 88 | /* 89 | @description: This method is used to actually gather both the fields record data and metadata information and merge it into a 90 | list of FieldData for display in the UI. 91 | */ 92 | private List getRecordData(List fieldSetFields, Id recordId, Integer characterWarningThreshold){ 93 | String query = 'SELECT '; 94 | Map fieldDataMap = new Map(); 95 | Map fieldMap = describes[0].fields.getMap(); 96 | for(Schema.FieldSetMember field: fieldSetFields){ 97 | FieldData fieldInfo = new FieldData(); 98 | fieldInfo.fieldApiName = field.getFieldPath(); 99 | query += fieldInfo.fieldApiName + ','; 100 | if(field.getType().name() == STRING_TEXT || field.getType().name() == TEXT_AREA_TEXT){ 101 | SObjectField fieldMetadata = fieldMap.get(field.getFieldPath()); 102 | fieldInfo.stringFieldLength = fieldMetadata.getDescribe().getLength(); 103 | fieldInfo.isString = true; 104 | } 105 | fieldDataMap.put(fieldInfo.fieldApiName, fieldInfo); 106 | } 107 | query = query.removeEnd(','); 108 | query += ' FROM ' + recordId.getSobjectType() + ' WHERE Id = \'' + recordId + '\' WITH SECURITY_ENFORCED'; 109 | fieldDataMap = queryForFieldData(query, fieldDataMap, characterWarningThreshold); 110 | return fieldDataMap.values(); 111 | } 112 | 113 | /* 114 | @description: This method is used for actually querying the data from our record for the fields we care about. 115 | */ 116 | private Map queryForFieldData(String query, Map fieldDataMap, Integer characterWarningThreshold){ 117 | List objectList; 118 | try{ 119 | objectList = Database.query(query); 120 | } 121 | catch(QueryException qe){ 122 | throw new CharacterCountingException('There was an issue retrieving data to display in your ' + 123 | 'fields due to the following problem: ' + qe.getMessage()); 124 | } 125 | if(!objectList.isEmpty()){ 126 | return fillOutStringFieldData(objectList, fieldDataMap, characterWarningThreshold); 127 | } 128 | else{ 129 | throw new CharacterCountingException('There was an issue building your data to display. The record id you passed ' + 130 | 'in was not present in the system. Please make sure the record you are working on wasn\'t removed from the system.'); 131 | } 132 | } 133 | 134 | /* 135 | @description: This method is used to fill out information relevant to strings to display their character counts appropriately in the UI. 136 | */ 137 | private Map fillOutStringFieldData(List objectList, Map fieldDataMap, Integer characterWarningThreshold){ 138 | Map fieldDataMapClone = fieldDataMap.clone(); 139 | for(SObject obj: objectList){ 140 | for(String fieldName: fieldDataMap.keySet()){ 141 | FieldData fieldInfo = fieldDataMap.get(fieldName); 142 | if(fieldInfo.isString){ 143 | String stringField = (String)obj.get(fieldInfo.fieldApiName); 144 | if(stringField == null){ 145 | fieldInfo.charactersRemaining = fieldInfo.stringFieldLength; 146 | fieldDataMapClone.put(fieldInfo.fieldApiName, fieldInfo); 147 | continue; 148 | } 149 | fieldInfo.currentLength = stringField.length(); 150 | fieldInfo.charactersRemaining = fieldInfo.stringFieldLength - fieldInfo.currentLength; 151 | if(fieldInfo.charactersRemaining <= characterWarningThreshold){ 152 | fieldInfo.belowCharsThreshold = true; 153 | } 154 | fieldDataMapClone.put(fieldInfo.fieldApiName, fieldInfo); 155 | } 156 | } 157 | } 158 | return fieldDataMapClone; 159 | } 160 | 161 | /* 162 | @description: This inner wrapper class is what allows us to merge our field set data, field metadata and field record data together 163 | to return a nice clean list of records to the UI for an easy display. 164 | */ 165 | public class FieldData{ 166 | @AuraEnabled 167 | public String fieldApiName; 168 | @AuraEnabled 169 | public Integer charactersRemaining; 170 | @AuraEnabled 171 | public Integer stringFieldLength; 172 | @AuraEnabled 173 | public Integer currentLength; 174 | @AuraEnabled 175 | public Boolean isString = false; 176 | @AuraEnabled 177 | public Boolean belowCharsThreshold = false; 178 | } 179 | 180 | /* 181 | @description: Custom exception type used for throwing errors from within this service class. 182 | */ 183 | @TestVisible 184 | private class CharacterCountingException extends Exception{} 185 | } -------------------------------------------------------------------------------- /force-app/main/default/classes/UniversalMocker.cls: -------------------------------------------------------------------------------- 1 | /************************************************************ 2 | 3 | *** @author: Suraj Pillai 4 | *** @group: Test Class 5 | *** @date: 01/2020 6 | *** @description: A universal class for mocking in tests. Contains a method for setting the return value for any method. Another method returns the number of times a method was called 7 | 8 | */ 9 | @isTest 10 | public with sharing class UniversalMocker implements System.StubProvider { 11 | private final Map>> argumentsMap = new Map>>(); 12 | private final Type mockedClass; 13 | private final Map mocksMap = new Map(); 14 | private final Map callCountsMap = new Map(); 15 | 16 | private Boolean isInSetupMode = false; 17 | private Boolean isInAssertMode = false; 18 | private Boolean isInGetArgumentMode = false; 19 | 20 | private String currentMethodName; 21 | private String currentParamTypesString; 22 | private Integer expectedCallCount; 23 | private Integer forInvocationNumber = 0; 24 | 25 | private String INVALID_STATE_ERROR_MSG = 'Mocker object state is invalid for this operation. Please refer to the Readme'; 26 | private String KEY_DELIMITER = '||'; 27 | 28 | public enum Times { 29 | OR_LESS, 30 | OR_MORE, 31 | EXACTLY 32 | } 33 | 34 | private UniversalMocker(Type mockedClass) { 35 | this.mockedClass = mockedClass; 36 | } 37 | 38 | public static UniversalMocker mock(Type mockedClass) { 39 | return new UniversalMocker(mockedClass); 40 | } 41 | 42 | public Object createStub() { 43 | return Test.createStub(this.mockedClass, this); 44 | } 45 | 46 | private String getClassNameFromStubbedObjectName(Object stubbedObject) { 47 | String result = 'DateTime'; 48 | try { 49 | DateTime typeCheck = (DateTime) stubbedObject; 50 | } catch (System.TypeException te) { 51 | String message = te.getMessage().substringAfter('Invalid conversion from runtime type '); 52 | result = message.substringBefore(' to Datetime'); 53 | } 54 | return result; 55 | } 56 | 57 | private String getCurrentKey() { 58 | String className = this.mockedClass.getName(); 59 | String retVal = className + KEY_DELIMITER + this.currentMethodName; 60 | if (this.currentParamTypesString != null) { 61 | retVal += KEY_DELIMITER + this.currentParamTypesString; 62 | } 63 | return retVal.toLowerCase(); 64 | } 65 | 66 | private String getKey(String className, String methodName) { 67 | return (className + KEY_DELIMITER + methodName).toLowerCase(); 68 | } 69 | 70 | private String getKey(String className, String methodName, List paramTypes) { 71 | return (className + KEY_DELIMITER + methodName + KEY_DELIMITER + this.getParamTypesString(paramTypes)).toLowerCase(); 72 | } 73 | 74 | private String getParamTypesString(List paramTypes) { 75 | String[] classNames = new List{}; 76 | for (Type paramType : paramTypes) { 77 | classNames.add(paramType.getName()); 78 | } 79 | return String.join(classNames, '-'); 80 | } 81 | 82 | private void resetState() { 83 | this.currentParamTypesString = null; 84 | this.currentMethodName = null; 85 | this.isInAssertMode = false; 86 | this.isInSetupMode = false; 87 | this.isInGetArgumentMode = false; 88 | this.forInvocationNumber = 0; 89 | } 90 | 91 | private boolean isAnyModeActive() { 92 | return this.isInSetupMode || this.isInAssertMode || this.isInGetArgumentMode; 93 | } 94 | 95 | public void setMock(String stubbedMethodName, Object returnValue) { 96 | String key = this.getKey(this.mockedClass.getName(), stubbedMethodName); 97 | this.mocksMap.put(key, returnValue); 98 | this.callCountsMap.put(key, 0); 99 | } 100 | 101 | public UniversalMocker when(String stubbedMethodName) { 102 | if (this.isAnyModeActive()) { 103 | throw new InvalidOperationException(INVALID_STATE_ERROR_MSG); 104 | } 105 | this.isInSetupMode = true; 106 | this.currentMethodName = stubbedMethodName; 107 | return this; 108 | } 109 | 110 | public UniversalMocker withParamTypes(List paramTypes) { 111 | if (!this.isAnyModeActive()) { 112 | throw new InvalidOperationException('Invalid order of operations. Must specify method name to mock/assert first'); 113 | } 114 | this.currentParamTypesString = this.getParamTypesString(paramTypes); 115 | return this; 116 | } 117 | 118 | public UniversalMocker thenReturn(Object returnObject) { 119 | if (!this.isInSetupMode) { 120 | throw new InvalidOperationException('Invalid order of operations. Must specify method name to mock/assert first'); 121 | } 122 | String key = this.getCurrentKey(); 123 | this.mocksMap.put(key, returnObject); 124 | if (!this.callCountsMap.containsKey(key)) { 125 | this.callCountsMap.put(key, 0); 126 | } 127 | this.resetState(); 128 | return this; 129 | } 130 | 131 | public UniversalMocker thenThrow(Exception exceptionToThrow) { 132 | return this.thenReturn(exceptionToThrow); 133 | } 134 | 135 | private String determineKeyToUseForCurrentStubbedMethod(Object stubbedObject, String stubbedMethodName, List listOfParamTypes) { 136 | String mockedClass = this.getClassNameFromStubbedObjectName(stubbedObject); 137 | String keyWithoutParamTypes = this.getKey(mockedClass, stubbedMethodName); 138 | String keyWithParamTypes = this.getKey(mockedClass, stubbedMethodName, listOfParamTypes); 139 | return this.callCountsMap.containsKey(keyWithParamTypes) ? keyWithParamTypes : keyWithoutParamTypes; 140 | } 141 | 142 | private void incrementCallCount(String key) { 143 | Integer count = this.callCountsMap.containsKey(key) ? this.callCountsMap.get(key) : 0; 144 | this.callCountsMap.put(key, count + 1); 145 | } 146 | 147 | private void saveArguments(List listOfParamNames, List listOfArgs, String key) { 148 | Map currentArgsMap = new Map(); 149 | if (!this.argumentsMap.containsKey(key)) { 150 | this.argumentsMap.put(key, new List>{ currentArgsMap }); 151 | } else { 152 | this.argumentsMap.get(key).add(currentArgsMap); 153 | } 154 | 155 | for (Integer i = 0; i < listOfParamNames.size(); i++) { 156 | currentArgsMap.put(listOfParamNames[i].toLowerCase(), listOfArgs[i]); 157 | } 158 | } 159 | 160 | public Object handleMethodCall( 161 | Object stubbedObject, 162 | String stubbedMethodName, 163 | Type returnType, //currently unused 164 | List listOfParamTypes, 165 | List listOfParamNames, 166 | List listOfArgs 167 | ) { 168 | if (this.isAnyModeActive()) { 169 | throw new InvalidOperationException(INVALID_STATE_ERROR_MSG); 170 | } 171 | String keyInUse = this.determineKeyToUseForCurrentStubbedMethod(stubbedObject, stubbedMethodName, listOfParamTypes); 172 | this.incrementCallCount(keyInUse); 173 | this.saveArguments(listOfParamNames, listOfArgs, keyInUse); 174 | 175 | Object returnValue = this.mocksMap.get(keyInUse); 176 | if (returnValue instanceof Exception) { 177 | throw (Exception) returnValue; 178 | } 179 | return returnValue; 180 | } 181 | 182 | public UniversalMocker assertThat() { 183 | if (this.isAnyModeActive()) { 184 | throw new InvalidOperationException(INVALID_STATE_ERROR_MSG); 185 | } 186 | this.isInAssertMode = true; 187 | return this; 188 | } 189 | 190 | public UniversalMocker method(String methodName) { 191 | if (!this.isInAssertMode) { 192 | throw new InvalidOperationException('Invalid order of operations. Method called without calling assertThat first'); 193 | } 194 | this.currentMethodName = methodName; 195 | return this; 196 | } 197 | 198 | public void wasCalled(Integer expectedCallCount, Times assertTypeValue) { 199 | if (!this.isInAssertMode) { 200 | throw new InvalidOperationException('Invalid order of operations. Method called without calling assertThat first'); 201 | } 202 | this.expectedCallCount = expectedCallCount; 203 | String currentKey = this.getCurrentKey(); 204 | Integer actualCallCount = this.callCountsMap.get(currentKey); 205 | String methodName = this.currentMethodName; 206 | this.resetState(); 207 | switch on assertTypeValue { 208 | when OR_LESS { 209 | system.assert(this.expectedCallCount >= actualCallCount, this.getMethodCallCountAssertMessage(methodName, 'less than or equal')); 210 | } 211 | when OR_MORE { 212 | system.assert(this.expectedCallCount <= actualCallCount, this.getMethodCallCountAssertMessage(methodName, 'more than or equal')); 213 | } 214 | when else { 215 | system.assertEquals(this.expectedCallCount, actualCallCount, this.getMethodCallCountAssertMessage(methodName, 'equal')); 216 | } 217 | } 218 | } 219 | 220 | public void wasNeverCalled() { 221 | if (!this.isInAssertMode) { 222 | throw new InvalidOperationException('Invalid order of operations. Method called without calling assertThat first'); 223 | } 224 | String currentKey = this.getCurrentKey(); 225 | Integer actualCallCount = this.callCountsMap.get(currentKey); 226 | String methodName = this.currentMethodName; 227 | this.resetState(); 228 | if (actualCallCount != null) { 229 | this.expectedCallCount = 0; 230 | system.assertEquals(this.expectedCallCount, actualCallCount, String.format('Method {0} was called 1 or more times', new List{ methodName })); 231 | } 232 | } 233 | 234 | private String getMethodCallCountAssertMessage(String methodName, String comparison) { 235 | return String.format('Expected call count for method {0} is not {1} to the actual count', new List{ methodName, comparison }); 236 | } 237 | 238 | public UniversalMocker forMethod(String stubbedMethodName) { 239 | if (this.isAnyModeActive()) { 240 | throw new InvalidOperationException(INVALID_STATE_ERROR_MSG); 241 | } 242 | this.isInGetArgumentMode = true; 243 | this.currentMethodName = stubbedMethodName; 244 | return this; 245 | } 246 | 247 | public UniversalMocker andInvocationNumber(Integer invocation) { 248 | if (!this.isInGetArgumentMode) { 249 | throw new InvalidOperationException('Invalid order of operations. Method called without calling \'forMethod\' first'); 250 | } 251 | this.forInvocationNumber = invocation; 252 | return this; 253 | } 254 | 255 | public Object getValueOf(String paramName) { 256 | if (!this.isInGetArgumentMode) { 257 | throw new InvalidOperationException('Invalid order of operations. Method called without calling \'forMethod\' first'); 258 | } 259 | String theKey = this.getCurrentKey(); 260 | Map paramsMap = argumentsMap.get(theKey).get(this.forInvocationNumber); 261 | if (!paramsMap.containsKey(paramName.toLowerCase())) { 262 | throw new IllegalArgumentException(String.format('Param name {0} not found for the method {1}', new List{ paramName, this.currentMethodName })); 263 | } 264 | Object returnValue = paramsMap.get(paramName.toLowerCase()); 265 | this.resetState(); 266 | return returnValue; 267 | } 268 | 269 | public Object getArgumentsMap() { 270 | if (!this.isInGetArgumentMode) { 271 | throw new InvalidOperationException('Invalid order of operations. Method called without calling \'forMethod\' first'); 272 | } 273 | String theKey = this.getCurrentKey(); 274 | Map returnValue = this.argumentsMap.get(theKey).get(this.forInvocationNumber); 275 | this.resetState(); 276 | return returnValue; 277 | } 278 | 279 | public class InvalidOperationException extends Exception { 280 | } 281 | } --------------------------------------------------------------------------------