Wednesday, April 26, 2017

EPiServer Custom XForm Column Download Control - Dojo Control


In one of our project, we had requirement to download custom column in csv through XForm. XForm itself has built-in feature to download csv file but it downloads all columns and with existing built-in column such as PostedDate.
Client wanted to download only selected columns even though we were recording lots of information in XForm.

I have built small Dojo Control which enables you to filter data by date using built-in dojo date control and includes columns only that are pre-defined during development. We can extend control to allow to provide additional columns on the fly.

How it is look likes




How to define property


[BackingType(typeof(PropertyXFormCustomColumnDownloadButton))]
[EditorDescriptor(EditorDescriptorType = typeof(XFormCustomColumnDownloadButtonEditorDescriptor))]
[XFormReferencedProperty("Form", "RewardNappyPromoData", "FirstName", "LastName", "AddressLine1", "AddressLine2", "Suburb", "City", "Option")]
[Display(Order = 66)]                                  
public virtual XFormCustomColumnExport XFormDownload { get; set; }


PropertyXFormCustomColumnDownloadButton

This is backing type class to enable the storage of value. We are not saving any value for this as this is just control to download existing records. But we can extend this to store list of columns that need to be exported.


[PropertyDefinitionTypePlugIn(Description = "A property to show button to custom download xfrom column", DisplayName = "Download XForm Data")]
    public class PropertyXFormCustomColumnDownloadButton : PropertyString
    {
        public override Type PropertyValueType
        {
            get { return typeof(XFormCustomColumnExport); }
        }
        public override object SaveData(PropertyDataCollection properties)
        {
            return String;
        }
        public override object Value
        {
            get { return new XFormCustomColumnExport(); }
            set { base.Value = value; }
        }
        public override IPropertyControl CreatePropertyControl()
        {
            //No support for legacy edit mode
            return null;
        }
    }


XFormCustomColumnDownloadButtonEditorDescriptor

This is editor descriptor to let EPiServer know what Js file to load and how to show the control. And set any predefined values that need to be passed to JS file so we can pass back those value from js to server during export.


/// <summary>
    /// Register an editor for XForm custom column download
    /// </summary>
    [EditorDescriptorRegistration(TargetType = typeof(XFormCustomColumnExport))]
    public class XFormCustomColumnDownloadButtonEditorDescriptor : EditorDescriptor
    {
        public const string Form_ID = "FormId";
        public const string Included_Columns = "IncludedColumns";
        public const string XForm_Export_Url = "XFormCustomColumnExportUrl";
        public const string Export_FileName = "ExportFileName";
        public XFormCustomColumnDownloadButtonEditorDescriptor()
        {
            ClientEditingClass = "template.editors.XFormCustomColumnDownload";
        }
        protected override void SetEditorConfiguration(ExtendedMetadata metadata)
        {
            var xFormReferencedPropertyAtt =
                metadata.Attributes.FirstOrDefault(f => f.GetType() == typeof(XFormReferencedPropertyAttribute)) as
                    XFormReferencedPropertyAttribute;
            if (xFormReferencedPropertyAtt != null)
            {
                /* Load xform property to get xform id */
                var propertyData = metadata.Model as PropertyData;
                if (propertyData != null)
                {
                    /* Load the Referenced XForm property to fetch the id */
                    var xForm = propertyData.Parent.GetPropertyValue<XForm>(xFormReferencedPropertyAtt.XFormReferencedPropertyName);
                    if (xForm != null)
                    {
                        EditorConfiguration[Form_ID] = xForm.Id.ToString();
                    }
                    if (xFormReferencedPropertyAtt.IncludedColumns != null &&
                        xFormReferencedPropertyAtt.IncludedColumns.Any())
                    {
                        EditorConfiguration[Included_Columns] = xFormReferencedPropertyAtt.IncludedColumns;
                    }
                    EditorConfiguration[Export_FileName] = xFormReferencedPropertyAtt.ExportFileName;
                }
            }
            EditorConfiguration[XForm_Export_Url] = ConfigurationManager.AppSettings[XForm_Export_Url];
            base.SetEditorConfiguration(metadata);
        }
    }


XFormReferencedPropertyAttribute

This is additional attribute to pass the file name to be exported, Referenced XForm property name from which values will be extracted and columns that need to extracted.


  [AttributeUsage(AttributeTargets.Property)]
  public class XFormReferencedPropertyAttribute : Attribute
  {
      public string ExportFileName;
      public string XFormReferencedPropertyName { get; set; }
      public IList<string> IncludedColumns { get; set; }
      public XFormReferencedPropertyAttribute(string xFormReferncedPropertyName, string exportFileName = null, params string[] includedColumns)
      {
          ExportFileName = exportFileName;
          XFormReferencedPropertyName = xFormReferncedPropertyName;
          IncludedColumns = includedColumns;
      }
  }


XFormCustomColumnExport

This is just place holder property type so EPiServer does not mess up any existing type. I tried to use String but some time it will mess up any existing property with type String.


/// <summary>
/// This class is used for Dojo XFormCustomColumnDownload Control
/// The reason for using seperate class it to make sure dojo control does not mess up other existing types
/// </summary>
public class XFormCustomColumnExport
{
}


Adding Js File and Updating module.config

We need to created Js File inside ClientResources -> Scripts -> Editors
and need to update module.config to let EPiServer know form where to fetch the file.
JS File for Custom XForm Download With Date Controls

template.editors.XFormCustomColumnDownload

the template in name is from module.config, the editors is the folder inside the ClientResources->Scripts folder and XFormCustomColumnDownload is the name of js file.



/*
Dojo widget for downloading custom column of xfrom.
*/
 
define([
    "dojo/_base/declare",
    "dojo/io/iframe",
    "dijit/_CssStateMixin",
    "dijit/_Widget",
    "dijit/_TemplatedMixin",
    "dijit/_WidgetsInTemplateMixin",
    "dijit/form/DateTextBox",
    "epi/shell/widget/_ValueRequiredMixin"
   
],
function (
    declare,
    iframe, /* iframe is required to post download csv file request, Ajax request is not able to download file*/
    _CssStateMixin,
    _Widget,
    _TemplatedMixin,
    _WidgetsInTemplateMixin,
    DateTextBox,
    _ValueRequiredMixin
) {
 
    return declare("template.editors.XFormCustomColumnDownload",
        [_Widget, _TemplatedMixin, _WidgetsInTemplateMixin, _CssStateMixin, _ValueRequiredMixin],
        {
            templateString:
                "<div data-dojo-attach-point=\"containerNode\" class=\"dijit dijitReset dijitInline dijitLeft\">\
                    <form id=\"frmXFormExtract\" Method=\"POST\" />\
                    <input type=\"text\" data-dojo-id=\"startDate\" data-dojo-attach-event=\"onChange:onStartDateChange\" data-dojo-type=\"dijit/form/DateTextBox\" />\
                    <input type=\"text\" data-dojo-id=\"endDate\" data-dojo-attach-event=\"onChange:onEndDateChange\" data-dojo-type=\"dijit/form/DateTextBox\" />\
                    <button type=\"button\" data-dojo-attach-event=\"onclick:onExportButtonClick\" class=\"\">Export</button>\
                </div> ",
 
            formId: null,
            exportFileName: null,
            xFormCustomColumnExportUrl: null,
            includedColumns: null,
 
            baseClass: "XFormCustomColumnDownload",
 
            helptext: "This will download xform values as csv file",
            _deferred: null,
            intermediateChanges: false,
 
            onExportButtonClick: function (e) {
 
                // Cancelling is requried for dojo iframe otherwise dojo iframe will never send any request after 1st request
                // http://stackoverflow.com/questions/13854908/dojo-io-iframe-send-does-not-send-a-request-on-second-time-onwards-in-dojo-1-8
                if (this._deferred) {
                    this._deferred.cancel();
                }
 
                // Dojo iframe sends merged array every time user clicks the export button
                // https://dojotoolkit.org/reference-guide/1.10/dojo/io/iframe.html
                // Iframe use was required to allow download of file otherwise ajax wont download file
                this._deferred = dojo.io.iframe.send({
                    url: this.xFormCustomColumnExportUrl,
                    form: "frmXFormExtract",
                    content: {
                        formId: this.formId,
                        exportFileName: this.exportFileName,
                        StartDate: startDate.toLocaleString(),
                        EndDate: endDate.toLocaleString(),
                        IncludedColumns: this.includedColumns
                    }
                });
        },
             
        onStartDateChange: function (e) {
            startDate.setValue(e);
        },
 
        onEndDateChange: function (e) {
            endDate.setValue(e);
        },
 
        destroy: function () {
        },
        postMixInProperties: function() {
 
            if (this.params.xFormCustomColumnExportUrl) {
                this.xFormCustomColumnExportUrl = this.params.xFormCustomColumnExportUrl;
            }
            if (this.params.formId) {
                this.formId = this.params.formId;
            }
            if (this.params.exportFileName) {
                this.exportFileName = this.params.exportFileName;
            }
            if (this.params.includedColumns) {
                this.includedColumns = this.params.includedColumns;
            }
        },
        postCreate: function () {
            // call base implementation
            this.inherited(arguments);
        }
    });
});


module.config

<?xml version="1.0" encoding="utf-8"?>
<module>
  <assemblies>
    <!-- This adds the current site into the modules" -->
    <add assembly="EPiServer.Reference.Commerce.Site" />
    
  </assemblies>

  <dojoModules>
    <add name="template" path="Scripts" />    
  </dojoModules>

  <clientResources>
    
  </clientResources>

<dojo>
  </dojo></module>

ExportDataController

I have Got ExportDataController under XFormCustomColumnDataExport Area. I am passing relative url to Js File through XFormCustomColumnDownloadButtonEditorDescriptor.


/// <summary>
       /// This function is called by dojo control. Url is also provided in app setting
       /// </summary>
       /// <param name="model"></param>
       /// <returns></returns>
       public FileResult Export(ExportDataModel model)
       {
           if(model == null || model.FormId == Guid.Empty)
               throw new NullReferenceException("Form Id is required");
           var xForm = XForm.CreateInstance(new Guid(model.FormId.ToString()));
           if(xForm == null)
               throw new ContentNotFoundException($"xForm with formId:{model.FormId} could not found");
           /* Fetch all the data within date period */
           var postedData = xForm.GetPostedData(model.StartDate ?? (DateTime) SqlDateTime.MinValue,
               model.EndDate ?? (DateTime)SqlDateTime.MaxValue);
           Func<List<string>, string> csvSafeRow = (rowValues) =>
           {
               return rowValues.Aggregate(string.Empty, (a, b) => $"{a},{b}", s => s.Trim(','));
           };
           /* Adding header */
           StringBuilder exportedData = new StringBuilder(csvSafeRow(model.UniqueIncludedColumns.Select(s => s.AsSafeCsvString()).ToList()));
           exportedData.AppendLine();
           /* Fetch the required column data and append to csv string builder*/
           foreach (var record in postedData)
           {
               var newRow = new List<string>();
               foreach (var header in model.UniqueIncludedColumns)
                   newRow.Add(record.GetValue(header).AsSafeCsvString());
               exportedData.Append(csvSafeRow(newRow));
               exportedData.AppendLine();
           }
           string fileName = string.IsNullOrWhiteSpace(model.ExportFileName) ? "XForm" : model.ExportFileName;
           return File(Encoding.ASCII.GetBytes(exportedData.ToString()), "text/csv", $"{fileName}.csv");
       }


Helper function


   /// <summary>
       /// Removes the New Line(\n,\r) from value and wraps string with Quotes to make it safe for csv file
       /// </summary>
       /// <param name="value"></param>
       /// <returns></returns>
       public static string AsSafeCsvString(this string value)
       {
           if (string.IsNullOrWhiteSpace(value))
               return string.Empty;
           return $"\"{value}\"".Replace("\r\n", " ").Replace("\n", " ").Replace("\r", " ");
       }
 

Monday, April 10, 2017

Adding Tax and Jurisdiction in EPiServer Commerce through Code

In one of my project I wanted to add the Tax Jurisdiction, Jurisdiction Group and Tax value through code, instead of editor going through commerce back-end system.

Therefore, i wrote little Migration Step to add Australian Tax Jurisdiction Group, Jurisdiction and Value.


[ServiceConfiguration(typeof(IMigrationStep))]
    public class ImportAustraliaTreasureSiteContent : IMigrationStep
    {
        public bool Execute(IProgressMessenger processMessenger)
        {
            _progressMessenger =  processMessenger;
            _progressMessenger.AddProgressMessageText("Adding/Updating Australia Market, Warehouse, Tax, Shipping and Payment Provider", false, 0);
            try
            {   /* Adding Tax */
                _progressMessenger.AddProgressMessageText("Adding/Updating Australia Jurisdiction and Tax", false, 0);
                AddJurisdictionAndTax(new MarketId("AUS"));

            }
            catch (Exception exception)
            {
                processMessenger.AddProgressMessageText("ImportTreasureSiteContent failed: " + exception.Message + "Stack trace:" + exception.StackTrace, true, 0);
                Log.Fatal(exception.Message, exception);
                return false;
            }
            return true;
        }


In AddJurisdictionAndTax we will first delete any existing australian Tax and Jurisdiction and then will re-add it again.


private static void AddJurisdictionAndTax(IMarket market)
        {
            /* Get Jurisdiction DTO to add/remove jurisdiction  */
            var jurisdictions = JurisdictionManager.GetJurisdictions(JurisdictionManager.JurisdictionType.Tax);

            RemoveAustralianJurisdictionAndTax(jurisdictions, market);
            AddAustralianJurisdictionAndTaxValue(jurisdictions, market);
        }



Removing the Existing Australian Jurisdiction and Tax


  private static void RemoveAustralianJurisdictionAndTax(JurisdictionDto jurisdictions, IMarket market)
        {
            var australiaJurisdictions = jurisdictions.Jurisdiction.Where(w => w.CountryCode == market.MarketId);
            var jurisdictionGroups = JurisdictionManager.GetJurisdictionGroups(JurisdictionManager.JurisdictionType.Tax);

            /* Get relation many to many table for australian jurisdiction */
            var relationsGrp = australiaJurisdictions.SelectMany(s => s.GetJurisdictionRelationRows());

            /* Save id for future jurisdiction group use */
            var relations = relationsGrp.Select(s => new { s.JurisdictionGroupId, s.JurisdictionId }).ToList();
            var ausJuriGrps =
                jurisdictionGroups.JurisdictionGroup.Where(
                    s => relations.Select(g => g.JurisdictionGroupId).Contains(s.JurisdictionGroupId));

            /* Deleting manay to many middle table */
            foreach (var rel in relationsGrp)
            {
                rel.Delete();
            }

            /* Deleting australian jurisdiction */
            foreach (var ausJuri in australiaJurisdictions)
            {
                ausJuri.Delete();
            }


            /* Get JurisfictionsGroup belong to Australian Jurisdictions */
            foreach (var ausJuriGrp in ausJuriGrps)
            {
                ausJuriGrp.Delete();
            }

           /* Saving delete objects */
            JurisdictionManager.SaveJurisdiction(jurisdictions);

            /* Fetching Tax DTO belongs to Australian Language */
            var taxDto = TaxManager.GetTaxDto(TaxType.SalesTax);
            var taxLanguage = taxDto.TaxLanguage.FirstOrDefault(w => w.LanguageCode == market.DefaultLanguage.Name);
            if (taxLanguage != null)
            {
                /* Deleting Australian language and Tax relation */
                taxLanguage.Delete();

                /* Get tax */
                var tax = taxDto.Tax.FirstOrDefault(t => t.TaxId == taxLanguage.TaxId);

                /* Deleting Australian Tax */
                tax.Delete();
            }
            TaxManager.SaveTax(taxDto);
        }


Adding Australian Jurisdiction and Tax


   private static void AddAustralianJurisdictionAndTaxValue(JurisdictionDto jurisdictions, IMarket market)
        {
            /* Add jurisdictions row */
            var jrow = jurisdictions.Jurisdiction.AddJurisdictionRow("Australian Tax", "NSW", market.Countries.FirstOrDefault(),
                 (int)JurisdictionManager.JurisdictionType.Tax, null, null, null, null,
                 null, null, AppContext.Current.ApplicationId, "Australian Tax");

            /* Add jurisdiction group row */
            var jgrow = jurisdictions.JurisdictionGroup.AddJurisdictionGroupRow(AppContext.Current.ApplicationId, "Australian Tax", (int)JurisdictionManager.JurisdictionType.Tax, "Australian Tax");

            /* Add relation between jurisdiction and jurisdiction group */
            var jrela = jurisdictions.JurisdictionRelation.AddJurisdictionRelationRow(jrow, jgrow);

            JurisdictionManager.SaveJurisdiction(jurisdictions);

            /* Add Tax */
            var taxDto = TaxManager.GetTaxDto(TaxType.SalesTax);
            var trow = taxDto.Tax.AddTaxRow((int)TaxType.SalesTax, "Australian Tax", 0, AppContext.Current.ApplicationId);
            var tlrow = taxDto.TaxLanguage.AddTaxLanguageRow("Australian Tax", market.DefaultLanguage.Name,
                trow);
            var tvrow = taxDto.TaxValue.AddTaxValueRow(10d, trow, "Sales", jgrow.JurisdictionGroupId, DateTime.Now,
                Guid.Empty);
            TaxManager.SaveTax(taxDto);
        }



Migraiton Step only runs once in life. So once you initialize a site for the first time. Migration step will kick in and will add tax value.