27 |
28 |
29 |
--------------------------------------------------------------------------------
/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 |
10 |
11 |
12 |
13 |
14 |
15 |
{field.paragraphText}
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
--------------------------------------------------------------------------------
/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 |
13 |
14 |
21 |
22 |
31 |
32 |
33 |
34 |
37 |
40 |
48 |
49 |
59 |
60 |
61 |
62 |
63 |
--------------------------------------------------------------------------------
/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 |
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 | 
25 |
26 | The Character Counter's Edit Two-Column Layout Example
27 | 
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:
Deploy to your org with packaging using one of the links below (HIGHLY SUGGESTED!!):
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 | ---
67 |
68 | # Setup and Installation Tutorial Video
69 |
70 | [](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