Created
January 13, 2022 09:33
-
-
Save ashkohli/fd1ca0a6234316d960445fb1576ce455 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| public class MultiQuotePDFController { | |
| public Opportunity OpportunityRecord { get; private set; } | |
| public OpportunityLineItem OpportunityLineItem {get; private set; } | |
| public Map<String, Integer> ProductNameToNoOfPriceUnitsMap { get; private set; } | |
| public Map<String, String> ProductNameToCurrencyMap { get; private set; } | |
| public Integer NoOfUnitsForBeaconReference { get; private set; } | |
| public List<String> OverallListOfUnits { get; private set; } | |
| Public List<String>OverallListOfCurrenciesAndUnits {get; private set;} | |
| Public List<String> quoteTotals {get; private set; } | |
| public Map<String, Set<String>> ProductNameToListOfUnits { get; private set; } | |
| public Map<String, Set<String>> CurrencytoPriceUnits { get; private set; } | |
| public List<OpportunityLineItem> OpportunityProductsList { get; private set; } | |
| public Multi_Quote_Groupings__mdt MultiQuoteConfigurations { get; private set; } | |
| public List<Item> ConvertedOpportunityProductsList { get; private set; } | |
| public Boolean IsError { get; private set; } | |
| String OpportunityId = ''; | |
| //Above are declaring variables to be used in this class | |
| public MultiQuotePDFController() { | |
| IsError = false; | |
| OverallListOfUnits = new List<String>(); | |
| ProductNameToListOfUnits = new Map<String, Set<String>>(); | |
| OpportunityId = ApexPages.currentPage().getParameters().get('id'); | |
| //if there is no error, create new list and map to pull the opportunity id from the page URL | |
| OpportunityProductsList = getOpportunityLineItems(OpportunityId); | |
| OpportunityRecord = getOpportunityDate(OpportunityProductsList); | |
| MultiQuoteConfigurations = getMultiQuoteConfiguration(OpportunityRecord.Mode_of_Transport__c, OpportunityRecord.Shipment_Type__c); | |
| if (MultiQuoteConfigurations != null) { | |
| populateRelatedData(); | |
| // | |
| List<AggregateResult> aggreageted_line_items = Database.query(queryConstructorAgg()); | |
| ConvertedOpportunityProductsList = convertItems(aggreageted_line_items); | |
| getPricesAndReferences(ConvertedOpportunityProductsList); | |
| } else { | |
| IsError = true; | |
| } | |
| } | |
| private List<OpportunityLineItem> getOpportunityLineItems(String opportunity_id) { | |
| return | |
| [SELECT Beacon_Reference_Id__c, Price_Unit__c,Equipment_Type_Pull__c,Opportunity.Requested_Valid_From_Date__c,Opportunity.Requested_Valid_To_Date__c, Starting_Point_Seaport__r.Name, | |
| Ending_Point_Seaport__r.Name, Ending_Point_City__c, Ending_Point_Postcode__c, Ending_Point_Country__c, | |
| Starting_Point_City__c, Starting_Point_Postcode__c, Starting_Point_Country__c, Ending_Point_Airport__r.Name, | |
| Starting_Point_Airport__r.Name, Price_Currency__c,Cost_Currency__c, Product2.Name, Price_Amount__c, | |
| Opportunity.Account.Name, Opportunity.Quoted_Date__c, Opportunity.Pricing_Notes__c,Unit_Cost__c,Opportunity.Quote_Total__c,Unit_Price__c, | |
| Opportunity.Mode_of_Transport__c, Opportunity.Shipment_Type__c, Opportunity.Quote_Expiration_Date__c,Valid_From__c,Valid_To__c,Unit_Price_Quote_Currency__c, quote_currency__c | |
| FROM OpportunityLineItem | |
| WHERE OpportunityID = :opportunity_id | |
| ORDER BY Product2.Name, Equipment_Type_Lookup__c]; | |
| } | |
| // return a list of opportunity line items using the parameter of opportunity ID and return all the field items in the above SOQL statement | |
| private Opportunity getOpportunityDate(List<OpportunityLineItem> opportunity_line_items) { | |
| if (!opportunity_line_items.isEmpty()) { | |
| OpportunityLineItem opportunity_line_item = opportunity_line_items.get(0); | |
| return opportunity_line_item.Opportunity; | |
| } | |
| return new Opportunity(); | |
| } | |
| private Multi_Quote_Groupings__mdt getMultiQuoteConfiguration(String mode_of_transport, String shipment_type) { | |
| List<Multi_Quote_Groupings__mdt> configs = [SELECT Single_Reference__c, Mode_of_Transport__c, Shipment_Type__c, Display_Arr_Airport__c, Display_Delivery__c, | |
| Display_Dep_Airport__c, Display_POD__c, Display_POL__c, Display_Pickup__c, Group_by_Arr_Airport__c, | |
| Group_by_Delivery__c, Group_by_Dep_Airport__c, Group_by_POD__c, Group_by_POL__c, Group_by_Pickup__c | |
| FROM Multi_Quote_Groupings__mdt | |
| WHERE Mode_of_Transport__c =: mode_of_transport | |
| AND Shipment_Type__c =: shipment_type | |
| LIMIT 1]; | |
| if (configs.isEmpty()) { | |
| ApexPages.addmessage(new ApexPages.message(ApexPages.severity.ERROR, 'Multi-Quote PDF is not available for this type of Opportunity')); | |
| return null; | |
| } | |
| return configs.get(0); | |
| } | |
| //create a list with the items in the SOQL statement if the mode and shipment type given in the metadata is present. Group by the POL, POD, Arrival Airport, Delivery, Pickup, and Depar Airport | |
| //if the shipment type and mode of transport do not match throw up an error message | |
| private void populateRelatedData() { | |
| ProductNameToListOfUnits = new Map<String, Set<String>>(); | |
| CurrencytoPriceUnits = new map<String, set<String>>(); | |
| ProductNameToNoOfPriceUnitsMap = new Map<String, Integer>(); | |
| ProductNameToCurrencyMap = new Map<String, String>(); | |
| String Quote_Currency; | |
| //the above method populateRelatedData creates new maps | |
| //ProductNameToListOfUnits is where the key, value (product, unit (equipment type)) | |
| //ProductNameToNoOfPriceUnitsMap is where the key, value (Product, number of price units) | |
| //ProductNameToCurrencyMap is where the key, value (Product, Currency (cost_currency)) | |
| Set<String> units_set = new Set<String>(); | |
| for (OpportunityLineItem opportunity_line_item : OpportunityProductsList) { | |
| String price_unit = (String.isNotBlank(opportunity_line_item.Equipment_Type_Pull__c)) ? opportunity_line_item.Equipment_Type_Pull__c : '-'; | |
| units_set.add(price_unit); | |
| if (!ProductNameToListOfUnits.containsKey(opportunity_line_item.Product2.Name)) { | |
| ProductNameToListOfUnits.put(opportunity_line_item.Product2.Name, new Set<String>()); | |
| } | |
| ProductNameToListOfUnits.get(opportunity_line_item.Product2.Name).add(price_unit); | |
| String cost_currency = String.isNotBlank(opportunity_line_item.Cost_Currency__c) ? opportunity_line_item.Cost_Currency__c : '-'; | |
| ProductNameToCurrencyMap.put(opportunity_line_item.Product2.Name, cost_currency); | |
| } | |
| //Create a set called units_set and go through all the opportunity products in the opportunitylineitem | |
| //if the Equipment Type Pull is not blank add the Equipment Type, otherwise put '-' | |
| //Add the responses to the string price_unit and add the string price_unit to the set units_Set | |
| //if the map ProductNameToListOfUnits contains the same product name,match the product name to the equipment type in the unit_set string | |
| // | |
| // In the map of ProductnameToListOfUnits get the product name and the equipment type from the opportunitylineitem | |
| // Create a string where if the cost_currency is not blank in the opportunitylineitem, pull the cost currency, otherwise '-' | |
| // Add the key and value to the ProductNameToCurrencyMap | |
| OverallListOfUnits.addAll(units_set); | |
| NoOfUnitsForBeaconReference = OverallListOfUnits.size(); | |
| //Add all the values in the units_set set to the variable OverallListOfUnits | |
| //The variable NoOfUnitsForBeaconReference will be the number of values in the OverallListOfUnits variable | |
| for (String key : ProductNameToListOfUnits.keySet()) { | |
| ProductNameToNoOfPriceUnitsMap.put(key, ProductNameToListOfUnits.get(key).size()); | |
| } | |
| } | |
| // for the ProductNameToListOfUnits map, get all the values and put the value (number of equipment types) in the map ProductNameToNoOfPriceUnitsMap | |
| private String queryConstructorAgg() { | |
| String query_string = 'SELECT MIN (Beacon_Reference_ID__c) beaconReference, '; | |
| //create a string and select the min Beacon Reference ID and call it beaconReference | |
| List<String> query_fields = new List<String>(); | |
| if (MultiQuoteConfigurations.Group_by_Arr_Airport__c) query_fields.add('Ending_Point_Airport__r.Name endingPointAirportName'); | |
| if (MultiQuoteConfigurations.Group_by_Delivery__c) query_fields.add('Ending_Point_City__c, Ending_Point_Postcode__c, Ending_Point_Country__c'); | |
| if (MultiQuoteConfigurations.Group_by_Dep_Airport__c) query_fields.add('Starting_Point_Airport__r.Name startingPointAirportName'); | |
| if (MultiQuoteConfigurations.Group_by_Pickup__c) query_fields.add('Starting_Point_City__c, Starting_Point_Postcode__c, Starting_Point_Country__c'); | |
| if (MultiQuoteConfigurations.Group_by_POD__c) query_fields.add('Ending_Point_Seaport__r.Name endingPointSeaportName'); | |
| if (MultiQuoteConfigurations.Group_by_POL__c) query_fields.add('Starting_Point_Seaport__r.Name startingPointSeaportName'); | |
| //create a new string called query fields | |
| //If the checkbox for the grouping = true, then add the field following it. For example, if Group By Airport is true, then add the Ending Airport name to the string | |
| query_string += String.join(query_fields, ', '); | |
| query_string += ' FROM OpportunityLineItem WHERE OpportunityID = :OpportunityId'; | |
| // concatenate the list with commas | |
| // concatenate the list with the following parameter of FROM OpportunityLineITem where OpportunityID = :OpportunityID | |
| query_string += ' GROUP BY '; | |
| List<String> group_fields = new List<String>(); | |
| if (MultiQuoteConfigurations.Group_by_Arr_Airport__c) group_fields.add('Ending_Point_Airport__r.Name'); | |
| if (MultiQuoteConfigurations.Group_by_Delivery__c) group_fields.add('Ending_Point_City__c, Ending_Point_Postcode__c, Ending_Point_Country__c'); | |
| if (MultiQuoteConfigurations.Group_by_Dep_Airport__c) group_fields.add('Starting_Point_Airport__r.Name'); | |
| if (MultiQuoteConfigurations.Group_by_Pickup__c) group_fields.add('Starting_Point_City__c, Starting_Point_Postcode__c, Starting_Point_Country__c'); | |
| if (MultiQuoteConfigurations.Group_by_POD__c) group_fields.add('Ending_Point_Seaport__r.Name'); | |
| if (MultiQuoteConfigurations.Group_by_POL__c) group_fields.add('Starting_Point_Seaport__r.Name'); | |
| query_string += String.join(group_fields, ', '); | |
| //take the final query string we created before and concatenate with GROUP BY | |
| //If the Group By checkbox is true, then add the following field | |
| return query_string; | |
| } | |
| //return the final string which would be something like this SELECT Min (Beacon_Reference_ID)... from OpportunityLineItem WHERE OpportunityID = :OpportunityID GROUP BY ... | |
| private List<Item> convertItems(List<AggregateResult> aggregate_line_items) { | |
| //create a list called convertItems and pass through a list called aggregate_line_items | |
| List<Item> line_items = new List<Item>(); | |
| //create a list called line_items | |
| for (AggregateResult item : aggregate_line_items) { | |
| //loop through the list of aggregate_line_items | |
| Item single_line_item = new Item(); | |
| //create a new items called single_line_item | |
| single_line_item.beaconReference = (String)item.get('beaconReference'); | |
| //Add the beaconReference to the single_line_item | |
| if (MultiQuoteConfigurations.Group_by_POL__c) { | |
| single_line_item.Starting_Point_Seaport_Name = (String)item.get('startingPointSeaportName'); | |
| //if checkbox GROUP by POL is marked then pull the starting Point Seaport name into the single_line_item item | |
| } | |
| if (MultiQuoteConfigurations.Group_by_POD__c) { | |
| single_line_item.Ending_Point_Seaport_Name = (String)item.get('endingPointSeaportName'); | |
| //if checkbox GROUP by POL is marked then pull the ending Point Seaport name into the single_line_item item | |
| } | |
| if (MultiQuoteConfigurations.Group_by_Dep_Airport__c) { | |
| single_line_item.Starting_Point_Airport_Name = (String)item.get('startingPointAirportName'); | |
| //if checkbox GROUP by POL is marked then pull the starting Point Airport name into the single_line_item item | |
| } | |
| if (MultiQuoteConfigurations.Group_by_Arr_Airport__c) { | |
| single_line_item.Ending_Point_Airport_Name = (String)item.get('endingPointAirportName'); | |
| //if checkbox GROUP by POL is marked then pull the Ending Point Airport name into the single_line_item item | |
| } | |
| if (MultiQuoteConfigurations.Group_by_Delivery__c) { | |
| single_line_item.Ending_Point_City = (String)item.get('Ending_Point_City__c'); | |
| single_line_item.Ending_Point_Postcode = (String)item.get('Ending_Point_Postcode__c'); | |
| single_line_item.Ending_Point_Country = (String)item.get('Ending_Point_Country__c'); | |
| populateDelivery(single_line_item); | |
| //if GROUP by Delivery is marked, then add ending point city, postcode and country | |
| } | |
| if (MultiQuoteConfigurations.Group_by_Pickup__c) { | |
| single_line_item.Starting_Point_City = (String)item.get('Starting_Point_City__c'); | |
| single_line_item.Starting_Point_Postcode = (String)item.get('Starting_Point_Postcode__c'); | |
| single_line_item.Starting_Point_Country = (String)item.get('Starting_Point_Country__c'); | |
| populatePickup(single_line_item); | |
| //if GROUP by Pickup is marked, then add ending point city, postcode and country | |
| } | |
| line_items.add(single_line_item); | |
| //Add all the above to the line_items list created befiore | |
| } | |
| return line_items; | |
| //return all the items in the line items list | |
| } | |
| private void populatePickup(Item single_line_item) { | |
| List<String> pickup_list = new List<String>(); | |
| if(String.isNotBlank(single_line_item.Starting_Point_City)) pickup_list.add(single_line_item.Starting_Point_City); | |
| if(String.isNotBlank(single_line_item.Starting_Point_Postcode)) pickup_list.add(single_line_item.Starting_Point_Postcode); | |
| if(String.isNotBlank(single_line_item.Starting_Point_Country)) pickup_list.add(single_line_item.Starting_Point_Country); | |
| single_line_item.Pickup = String.join(pickup_list, ', '); | |
| } | |
| // populatePickup method passing through the item single_line_item | |
| // create a list called pickup_list | |
| // if single_line_item item has a Starting Point city, add the Starting Point city to the list | |
| // if single_line_item item has a postcode, add the postcode to the list | |
| // if single_line_item item has a starting point country, add it to the list | |
| // the pickup value in the single_line_item = the string created before and is concatenated by a comma | |
| private void populateDelivery(Item single_line_item) { | |
| List<String> delivery_list = new List<String>(); | |
| if(String.isNotBlank(single_line_item.Ending_Point_City)) delivery_list.add(single_line_item.Ending_Point_City); | |
| if(String.isNotBlank(single_line_item.Ending_Point_Postcode)) delivery_list.add(single_line_item.Ending_Point_Postcode); | |
| if(String.isNotBlank(single_line_item.Ending_Point_Country)) delivery_list.add(single_line_item.Ending_Point_Country); | |
| single_line_item.Delivery = String.join(delivery_list, ', '); | |
| } | |
| // populateDelivery method passing through the item single_line_item | |
| // create a list called delivery_list | |
| // if single_line_item item has a Ending Point city, add the Ending Point city to the list | |
| // if single_line_item item has a postcode, add the postcode to the list | |
| // if single_line_item item has a Ending point country, add it to the list | |
| // the delivery value in the single_line_item = the string created before and is concatenated by a comma | |
| public void getPricesAndReferences(List<Item> items) { | |
| for (Item i : items) { | |
| i.pricesMap = new Map<String, Double>(); | |
| i.referencesMap = new Map<String, String>(); | |
| i.totalsMap = new Map <String,Double>(); | |
| i.quoteCurrencyTotal = new map <String, Double>(); | |
| i.costCurrencyTotal = new map <String, Double>(); | |
| i.currToTotalMap = new map <String,Double>(); | |
| if (MultiQuoteConfigurations.Single_Reference__c) { | |
| NoOfUnitsForBeaconReference = 1; | |
| } | |
| for (OpportunityLineItem opportunity_line_item : OpportunityProductsList) { | |
| String price_unit = (String.isNotBlank(opportunity_line_item.Equipment_Type_Pull__c)) ? opportunity_line_item.Equipment_Type_Pull__c : '-'; | |
| String quote_currency = (String.isNotBlank(opportunity_line_item.Quote_Currency__c)) ? opportunity_line_item.Quote_Currency__c : '-'; | |
| String cost_currency = (String.isNotBlank(opportunity_line_item.Cost_Currency__c)) ? opportunity_line_item.Cost_Currency__c : '-'; | |
| if (i.isRelatedRecord(opportunity_line_item, MultiQuoteConfigurations)) { | |
| i.pricesMap.put(opportunity_line_item.Product2.Name + ' ' + price_unit, opportunity_line_item.Unit_Price__c != null ? opportunity_line_item.Unit_Price__c : null); | |
| i.referencesMap.put(price_unit, opportunity_line_item.Beacon_Reference_ID__c !=null ? opportunity_line_item.Beacon_Reference_ID__c : '-'); | |
| i.totalsMap.put(price_unit, opportunity_line_item.unit_price_quote_currency__c !=null ? opportunity_line_item.unit_price_quote_currency__c : null); | |
| i.quoteCurrencyTotal.put(quote_currency + ' ' + price_unit, opportunity_line_item.Unit_Price_Quote_Currency__c !=null ? opportunity_line_item.Unit_Price_Quote_Currency__c: null); | |
| i.costCurrencyTotal.put(cost_currency + ' ' + price_unit, opportunity_line_item.Unit_Price__c !=null ? opportunity_line_item.Unit_Price__c: null); | |
| } else { | |
| i.pricesMap.put(opportunity_line_item.Product2.Name + ' ' + price_unit, null); | |
| i.referencesMap.put(price_unit, '-'); | |
| i.totalsMap.put(price_unit, null); | |
| i.quoteCurrencyTotal.put(quote_currency + ' ' +price_unit,null); | |
| i.costCurrencyTotal.put(cost_currency + ' ' + price_unit,null); | |
| } | |
| map<String,Double>costCurrencyTotal; | |
| map<String, Double> currToTotalMap = new map<String,Integer>(); | |
| for(String key : costCurrencyTotal.keyset()){ | |
| list<String> strList = key.split(' '); | |
| if(currToTotalMap.containsKey(strList[1])){ | |
| Double tempTotal = currToTotalMap.get(strList[1]); | |
| currToTotalMap.put(strList[1], (tempTotal+ costCurrencyTotal.get(key))); | |
| }else{ | |
| currToTotalMap.put(strList[1], costCurrencyTotal.get(key)); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Public static Map<String,Double>sumMaps(Map<String,Double>quoteCurrencyTotal, Map<String,Double>costCurrencyTotal) { | |
| // Set<String> keys1 = quoteCurrencyTotal.keySet(); | |
| //Set<String> keys2 = costCurrencyTotal.keySet(); | |
| // keys1.retainAll(keys2); | |
| //Map<String,Double>summedMap = new Map<String,Double>(); | |
| //for(String k : keys1) { | |
| // summedMap.put(k, quoteCurrencyTotal.get(k) + costCurrencyTotal.get(k)); | |
| // } | |
| //return summedMap; | |
| //loop through the opportunityLineItems in the OpportunityProductsList | |
| //for the string price_units if the equipment type is not blank, pull the equipment type, otherwise put '-' | |
| // if the opportunitylineitem and the MultiQuoteConfigurations are related records, then pull the Product Name + equipment type, the unit price in the PricesMap | |
| // if the opportunitylineitem and the MultiQuoteConfigurations are related records, the put the equipment type, beacon reference id into the ReferencesMap | |
| // Otherwise put Product Name and Equipment Type, and 0 in the PricesMap | |
| // Otherwise put equipment type and '-' in the referencesMap | |
| public class Item { | |
| public Decimal quoteTotal {get;set;} | |
| public String beaconReference {get;set;} | |
| public String Starting_Point_Seaport_Name {get;set;} | |
| public String Ending_Point_Seaport_Name {get;set;} | |
| public String Starting_Point_Airport_Name {get;set;} | |
| public String Ending_Point_Airport_Name {get;set;} | |
| public String Ending_Point_City {get;set;} | |
| public String Ending_Point_Postcode {get;set;} | |
| public String Ending_Point_Country {get;set;} | |
| public String Starting_Point_City {get;set;} | |
| public String Starting_Point_Postcode {get;set;} | |
| public String Starting_Point_Country {get;set;} | |
| public String Pickup {get;set;} | |
| public String Delivery {get;set;} | |
| public Map<String, Decimal> pricesMap {get;set;} | |
| public Map<String, String> referencesMap {get;set;} | |
| public Map<String, Decimal> totalsMap {get;set;} | |
| public Map<String, Decimal>quoteCurrencyTotal {get;set;} | |
| public Map<String, Decimal>costCurrencyTotal {get;set;} | |
| public Map<String, Decimal>currToTotalMap {get;set;} | |
| //create new variables in a class | |
| public Boolean isRelatedRecord(OpportunityLineItem record_to_compare, Multi_Quote_Groupings__mdt multi_quote_configurations) { | |
| //public method that passes through the record to compare from the OpportunityLineItem and multi quote configurations from the MQG mdt | |
| Set<Boolean> configuration_check_list = new Set<Boolean>(); | |
| //Create a new boolean set called configuration check list | |
| if (multi_quote_configurations.Group_by_POL__c) { | |
| configuration_check_list.add(record_to_compare.Starting_Point_Seaport__r.Name == this.Starting_Point_Seaport_Name); | |
| //if Group by POL is marked, then add the Starting Seaport Name to the set | |
| } | |
| if (multi_quote_configurations.Group_by_POD__c) { | |
| configuration_check_list.add(record_to_compare.Ending_Point_Seaport__r.Name == this.Ending_Point_Seaport_Name); | |
| //if Group by POD is marked, then add the Ending Seaport Name to the set | |
| } | |
| if (multi_quote_configurations.Group_by_Dep_Airport__c) { | |
| configuration_check_list.add(record_to_compare.Starting_Point_Airport__r.Name == this.Starting_Point_Airport_Name); | |
| //if Group by Dep Airport is marked, then add the Ending Seaport Name to the set | |
| } | |
| if (multi_quote_configurations.Group_by_Arr_Airport__c) { | |
| configuration_check_list.add(record_to_compare.Ending_Point_Airport__r.Name == this.Ending_Point_Airport_Name); | |
| //if Group by Arr Airport is marked, then add the Ending Seaport Name to the set | |
| } | |
| if (multi_quote_configurations.Group_by_Pickup__c) { | |
| configuration_check_list.add(record_to_compare.Starting_Point_City__c == this.Starting_Point_City); | |
| configuration_check_list.add(record_to_compare.Starting_Point_Postcode__c == this.Starting_Point_Postcode); | |
| configuration_check_list.add(record_to_compare.Starting_Point_Country__c == this.Starting_Point_Country); | |
| //if group by Pickup is selected, then add starting point city, starting point postcode, and starting point country | |
| } | |
| if (multi_quote_configurations.Group_by_Delivery__c) { | |
| configuration_check_list.add(record_to_compare.Ending_Point_City__c == this.Ending_Point_City); | |
| configuration_check_list.add(record_to_compare.Ending_Point_Postcode__c == this.Ending_Point_Postcode); | |
| configuration_check_list.add(record_to_compare.Ending_Point_Country__c == this.Ending_Point_Country); | |
| //if group by delivery is selected, then add ending point city, ending point postcode, and ending point country | |
| } | |
| return !configuration_check_list.contains(false); | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment