Tuesday, December 19, 2017

Azure Storage Account File Storage Wrapper For Basic Functions

In one of our project we had to upload/download files to azure file storage.
Therefore, I build this small wrapper classes to make use of Azure file storage easy.

AzureStorageAccountClient

This client is supposed to provide function to connect to different storage account such as (Blob, File and etc storage)


    public class AzureStorageAccountClient
    {
        private readonly ILogger logger = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType);

        public AzureStorageAccountClient(string connectionString)
        {
            ConnectionString = connectionString;
        }

        public string ConnectionString { get; }

        public IAzureStorageShareClient FileClientConnect()
        {
            string message = string.Empty;
            try
            {
                return new AzureStorageShareClient(CloudStorageAccount.Parse(ConnectionString).CreateCloudFileClient());
            }
            catch (Exception ex)
            {
                logger.Critical("Error in Azure Client", ex);
                throw;
            }
        }
    }


AzureStorageShareClient

This class provides functions to perform on share storage client.


   public interface IAzureStorageShareClient
    {
        CloudFileClient CloudFileClient { get; }
    }

    public class AzureStorageShareClient : IAzureStorageShareClient
    {
        public AzureStorageShareClient(CloudFileClient cloudFileClient)
        {
            CloudFileClient = cloudFileClient;
        }

        public CloudFileClient CloudFileClient { get; }
    }   



AzureStorageFileDirectoryClient

This class provides functionality to different Directoy related operations.


    public interface IAzureStorageFileDirectoryClient
    {
        CloudFileShare CloudFileShare { get; }

        CloudFileDirectory CloudFileDirectory { get; set; }
    }

    public class AzureStorageFileDirectoryClient : IAzureStorageFileDirectoryClient
    {
        public AzureStorageFileDirectoryClient(CloudFileShare cloudFileShare)
        {
            CloudFileShare = cloudFileShare;
        }

        public CloudFileShare CloudFileShare { get; }

        public CloudFileDirectory CloudFileDirectory { get; set; }
    }


AzureStorageFileClient

This class provides functions to different File related operations


    public interface IAzureStorageFileClient
    {
        CloudFileDirectory CloudFileDirectory { get; set; }

        CloudFile CloudFile { get; }
    }

    public class AzureStorageFileClient : IAzureStorageFileClient
    {
        public AzureStorageFileClient(CloudFile cloudFile)
        {
            CloudFile = cloudFile;
        }

        public CloudFileDirectory CloudFileDirectory { get; set; }

        public CloudFile CloudFile { get; }
    }



As you have noted none of the above classes have any functions.
Below is the extension class that provides functions available to those interfaces.

AzureStorageAccountExtensions


    public static class AzureStorageAccountExtensions
    {
        public static async Task GetOrAddShare(this IAzureStorageShareClient client, string shareName)
        {
            var cloudFileShare = client.CloudFileClient.GetShareReference(shareName);
            await cloudFileShare.CreateIfNotExistsAsync().ConfigureAwait(false);
            return new AzureStorageFileDirectoryClient(cloudFileShare);
        }

        public static async Task GetOrAddFolder(this IAzureStorageFileDirectoryClient client, string folderPath)
        {
            var cloudRootDirectory = client.CloudFileShare.GetRootDirectoryReference();
            var cloudFileDirectory = cloudRootDirectory.GetDirectoryReference(folderPath);
            await cloudFileDirectory.CreateIfNotExistsAsync().ConfigureAwait(false);
            client.CloudFileDirectory = cloudFileDirectory;
            return client;
        }

        public static IEnumerable ListFiles(this IAzureStorageFileDirectoryClient client)
        {
            return client.CloudFileDirectory.ListFilesAndDirectories().Where(f => !f.Uri.LocalPath.Contains(".folder"));
        }

        public static async Task AddFile(this IAzureStorageFileDirectoryClient client, string fileName, long length)
        {
            await client.CloudFileDirectory.CreateIfNotExistsAsync().ConfigureAwait(false);

            var cloudFile = client.CloudFileDirectory.GetFileReference(fileName);
            if (!await cloudFile.ExistsAsync().ConfigureAwait(false))
            {
                await cloudFile.CreateAsync(length).ConfigureAwait(false);
            }

            return new AzureStorageFileClient(cloudFile);
        }

        public static async Task GetFile(this IAzureStorageFileDirectoryClient client, string fileName)
        {
            var cloudFile = client.CloudFileDirectory.GetFileReference(fileName);
            if (await cloudFile.ExistsAsync().ConfigureAwait(false))
            {
                using (MemoryStream ms = new MemoryStream())
                {
                    await cloudFile.DownloadToStreamAsync(ms).ConfigureAwait(false);
                    return ms.ToArray();
                }
            }

            return null;
        }

        public static async void UploadFile(this IAzureStorageFileClient client, byte[] data)
        {
            await client.CloudFile.UploadFromByteArrayAsync(data, 0, data.Length).ConfigureAwait(false);
        }
    }



Tuesday, October 17, 2017

EPiFind AndAny/OrAny Filter Expression Builder

Extending the EPiServer Find Functionality to support OrAny or AndAny Filter Expression

The OrAny and AndAny filter for EPiFind will allow the developer to combine list of contents as And or Or with the other query conditions.

We had custom property Tag and TagList class which we wanted EPiFind to index so we can query the contents based on those filters.

The Tag and TagList class looks like below

Tag Class

[JsonConverter(typeof(TagJsonConverter))]
    public class Tag
    {
        private string text;
        private string value;
        private string tag;
        private bool isLoaded;

        public string Text
        {
            get
            {
                if (!isLoaded)
                    Load();

                return text;
            }

            set
            {
                text = value;
            }
        }

        public string Value
        {
            get
            {
                if (!isLoaded)
                    Load();

                return value;
            }

            set
            {
                this.value = value;
            }
        }

        public Tag()
        { }

        public Tag(string tag)
        {
            this.tag = tag;
        }

        public Tag(string value, string text)
        {
            isLoaded = true;
            this.value = value;
            this.text = text;
        }

        public string GetId(string type) => Value?.Substring(type.Length);

        public override string ToString()
        {
            return $"{Value}{Constants.Tags.NameDelimiter}{Text}";
        }

        private void Load()
        {
            isLoaded = true;
            if (!string.IsNullOrWhiteSpace(tag))
            {
                var parts = tag.Split(new[] { Constants.Tags.NameDelimiter }, 2);
                if (parts.Length == 1)
                {
                    text = parts[0];
                    value = string.Empty;
                }
                else
                {
                    value = parts[0];
                    text = parts[1];
                }
            }
        }

        public static Tag Parse(string tag)
        {
            return string.IsNullOrWhiteSpace(tag) ? null : new Tag(tag);
        }
    }


TagList Class

[JsonConverter(typeof(TagListJsonConverter))]
    public class TagList : IEnumerable
    {
        private Tag[] tags;
        private string tag;
        private bool isLoaded;

        public TagList() { }

        public TagList(string tag)
        {
            tags = new Tag[0];
            this.tag = tag;
        }

        public TagList(IEnumerable tags)
        {
            isLoaded = true;
            this.tags = tags.ToArray();
        }

        public override string ToString() => string.Join(Constants.Tags.ValueDelimiter.ToString(), this.Select(s => s.ToString()));

        public IEnumerator GetEnumerator()
        {
            if (!isLoaded)
                Load();

            return ((IEnumerable)this.tags).GetEnumerator();
        }

        IEnumerator IEnumerable.GetEnumerator()
        {
            return this.GetEnumerator();
        }

        private void Load()
        {
            isLoaded = true;
            if (!string.IsNullOrWhiteSpace(tag))
                tags = tag.Split(new char[] { Constants.Tags.ValueDelimiter }, StringSplitOptions.RemoveEmptyEntries).Select(s => new Tag(s)).ToArray();
        }
    }

We use the Tag and TagList class like below just for reference so you can get a context

[Display(Order = 20, Description = "Start typing the topic name to see suggestions. Allows multiple.")]
[AutoSuggestTags(typeof(TopicSelectionQuery))]
[BackingType(typeof(PropertyTagList))]
public virtual TagList Topics { get; set; }

[BackingType(typeof(PropertyTag))]
[Display(Order = 30, Description = "Select competition to filter the contents of this section block")]
[LinkedAutoSelection(typeof(CompetitionTagSelectionQuery), null, null, new[] { "/season/topic", "/round/topic" })]
public virtual Tag Competition { get; set; }


The Problem

We wanted to filter the contents by applying filter on Tag and TagList class. Therefore, we build a filter expression using the below code for different property.

OrTopic Filter Builder

public static ITypeSearch TopicOrFilter(this ITypeSearch typeSearchQuery, IEnumerable values)
          where T : ContentPage
        {
            if (values.IsNullOrEmpty())
                return typeSearchQuery;

            FilterBuilder filterBuilder = new FilterBuilder(typeSearchQuery.Client);

            foreach (var value in values)
            {
                filterBuilder = filterBuilder.Or(t => t.Topic.Match(value));
            }

            return typeSearchQuery.Filter(filterBuilder);
        }


but we wanted this to be generic so we do not have to add separate function for each paramter.

Therefore we added below generic code

The AndAny Function

public static ITypeSearch AndAny(this ITypeSearch typeSearchQuery, IEnumerable values, Expression> filter)
            where T : ContentPage
        {
            if (values.IsNullOrEmpty())
                return typeSearchQuery;

            FilterBuilder filterBuilder = new FilterBuilder(typeSearchQuery.Client);

            /* Expression visitor to modify provided filter to epiFind filter expression */
            var resolver = new ParameterReplacerVisitor, Func>();

            /* Parameter to be replaced with constant */
            var parameter = filter.Parameters[1];
            foreach (var value in values)
            {
                resolver.Source = parameter;
                resolver.Value = value;

                filterBuilder = filterBuilder.Or(resolver.ReplaceValue(filter));
            }

            return typeSearchQuery.Filter(filterBuilder);
        }

The OrAny Function

public static ITypeSearch OrAny(this ITypeSearch typeSearchQuery, IEnumerable values, Expression> filter)
            where T : ContentPage
        {
            if (values.IsNullOrEmpty())
                return typeSearchQuery;

            FilterBuilder filterBuilder = new FilterBuilder(typeSearchQuery.Client);

            /* Expression visitor to modify provided filter to epiFind filter expression */
            var resolver = new ParameterReplacerVisitor, Func>();

            /* Parameter to be replaced with constant */
            var parameter = filter.Parameters[1];
            foreach (var value in values)
            {
                resolver.Source = parameter;
                resolver.Value = value;

                filterBuilder = filterBuilder.Or(resolver.ReplaceValue(filter));
            }

            return typeSearchQuery.OrFilter(filterBuilder);
        }

The Other Problem.

The EPiFind filter exprssion expects fitler expression with one parameter but our expression contains the two parameter. Therefore, we need to resolve parameter expression to align with the functions available for filter builder.

public class ParameterReplacerVisitor : System.Linq.Expressions.ExpressionVisitor
    {

        public ParameterReplacerVisitor()
        {
        }

        public ParameterExpression Source { get; set; }

        public object Value { get; set; }

        public Expression ReplaceValue(Expression exp)
        {
            var expNew = Visit(exp) as LambdaExpression;
            return Expression.Lambda(expNew.Body, expNew.Parameters);
        }

        /// 
        /// This functoin will replace the parameter expression with constant values if parameter name and type matches with provided value
        /// for e.g (num1, num2) => num1 + num2 will be replaced (num1) => num1 + 3. if we want to replace num2 with 3.
        /// 
        protected override Expression VisitParameter(ParameterExpression node)
        {
            // Replace the source with the target, visit other params as usual.
            if (node.Type == Source.Type && node.Name == Source.Name)
            {
                return Expression.Constant(Value);
            }

            return base.VisitParameter(node);
        }

        /// 
        /// This functoin will replace the member expression with constant values if parameter name and type matches with provided value
        /// for e.g (num1, classObj) => num1 + classObj.num2 will be replaced (num1) => num1 + 3. if we want to replace classObj.num2 with 3.
        /// 
        protected override Expression VisitMember(MemberExpression m)
        {
            if (m.Expression != null
                && m.Expression.NodeType == ExpressionType.Parameter
                && m.Expression.Type == Source.Type && ((ParameterExpression)m.Expression).Name == Source.Name)
            {
                object newVal;
                if (m.Member is FieldInfo)
                    newVal = ((FieldInfo)m.Member).GetValue(Value);
                else if (m.Member is PropertyInfo)
                    newVal = ((PropertyInfo)m.Member).GetValue(Value, null);
                else
                    newVal = null;
                return Expression.Constant(newVal);
            }

            return base.VisitMember(m);
        }

        /// 
        /// In this function we are reducing the number of parameters in lambda expression
        /// for e.g as we are replacing parameter with constants therefore, (num1, num2) => will become (num1) =>
        /// 
        protected override Expression VisitLambda(Expression node)
        {
            var parameters = node.Parameters.Where(p => p.Name != Source.Name || p.Type != Source.Type).ToList();
            return Expression.Lambda(Visit(node.Body), parameters);
        }
    }



Monday, October 9, 2017

Custom Property In EPiServer And EPiFInd Indexing

In one of our project we had to build the autosuggest property. However, the default type of autosuggest property is string but we wanted to store custom object so we can index through EPiFind with additional values.

Therefore, we implemented custom class to store additional values and also needed JsonConverter for 2 reasons

  1. Convert Class to String for Dojo Framework so existing autosuggest property works as expected
  2. Convert and index object for EPiFind differently
We will delimte the value of autosuggest property with '-' to break it into multiple property and save it and index it properly.

Define the property


[BackingType(typeof(PropertyTag))]
[Display(Order = 45, Description = "Select competition to filter the contents of the page")]
[AutoSuggestSelection(typeof(TagSelectionQuery), AllowCustomValues = false)]
public virtual Tag Competition { get; set; }


Define the PropertyTag Class



    [PropertyDefinitionTypePlugIn(Description = "A property to tag content", DisplayName = "Tag")]
    [JsonConverter(typeof(TagJsonConverter))]
    public class PropertyTag : PropertyLongString
    {
        public override PropertyDataType Type => PropertyDataType.LongString;

        public override Type PropertyValueType => typeof(Tag);

        public override object Value
        {
            get { return Tag.Parse(base.Value?.ToString()); }
            set { base.Value = value?.ToString(); }
        }

        public override object SaveData(PropertyDataCollection properties)
        {
            return this.LongString;
        }
    }


Define the JsonConverter



 internal class TagJsonConverter : JsonConverter
    {
        public override bool CanConvert(Type objectType)
        {
            return true;
        }

        public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
        {
            if (reader.Value is Tag tag)
                return tag;

            return new Tag(reader.Value.ToString());
        }

        public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
        {
            if (value == null)
            {
                writer.WriteNull();
                return;
            }

            if (writer is MaxDepthJsonWriter && value is Tag tag)
            {
                writer.WriteStartObject();
                writer.WritePropertyName($"Text{TypeSuffix.String}");
                writer.WriteValue(tag.Text);
                writer.WritePropertyName($"Value{TypeSuffix.String}");
                writer.WriteValue(tag.Value);
                writer.WriteEndObject();
                return;
            }

            if (value is Tag itag)
            {
                writer.WriteValue(itag.ToString());
            }
            else
            {
                writer.WriteValue(value.ToString());
            }
        }
    }


Define Tag Class




 [JsonConverter(typeof(TagJsonConverter))]
    public class Tag
    {
        private string text;
        private string value;
        private string tag;
        private bool isLoaded;

        public string Text
        {
            get
            {
                if (!isLoaded)
                    Load();

                return text;
            }

            set
            {
                text = value;
            }
        }

        public string Value
        {
            get
            {
                if (!isLoaded)
                    Load();

                return value;
            }

            set
            {
                this.value = value;
            }
        }

        public Tag()
        { }

        public Tag(string tag)
        {
            this.tag = tag;
        }

        public Tag(string value, string text)
        {
            isLoaded = true;
            this.value = value;
            this.text = text;
        }

        public string GetId(string type) => Value?.Substring(type.Length);

        public override string ToString()
        {
            return $"{Value}{ "-" }{Text}";
        }

        private void Load()
        {
            isLoaded = true;
            if (!string.IsNullOrWhiteSpace(tag))
            {
                var parts = tag.Split(new[] { "-" }, 2);
                if (parts.Length == 1)
                {
                    text = parts[0];
                    value = string.Empty;
                }
                else
                {
                    value = parts[0];
                    text = parts[1];
                }
            }
        }

        public static Tag Parse(string tag)
        {
            return string.IsNullOrWhiteSpace(tag) ? null : new Tag(tag);
        }
    }

Define TagSelectionQuery


    [ServiceConfiguration(typeof(ISelectionQuery))]
    public class TagSelectionQuery : ISelectionQuery
    {
        private const int Depth = 0;

        protected virtual string Prefix { get; } = Constants.Tags.Competition;

        public IEnumerable GetItems(string query)
        {
            /* Return List Of Select Items With Value Delimited by '-' For value and text of tag
        }

        ///         /// Will be called when initializing an editor with an existing value to get the corresponding text representation.
        ///         public ISelectItem GetItemByValue(string value) => /* Return SelectItem */;
    }


Pro Tip: Make Sure Admin For Property Looks Like below

Tuesday, August 29, 2017

Twitter And Instagram Content Provider for EPiServer

In our project we had to bring the twitter and instagram content as content in EPiServer so editor can dragged twitter and instagram into content area.

Firstly, Our company uses spredfast to manage the social content. therefore, we had to use spredfast api to fetch the twitter and instagram content and show it as content in EPiServer content provider.

I am not going to show the spredfast api part. We will assume we are fetching twitter and instagram through api and saving it in our local database as json. We will retrieve this json coming from our local database and convert that json c# classes to EPiServer contetn classes and serve it to EPiServer content provider.

It is how it looks like



This is how it looks like after dragging  four Social Content in Content Area


Parent class for Social Post For Json



using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;

namespace NRL.Shared.Models.Social
{
    public abstract class SocialPost
    {
        public string Entity_id { get; set; }

        public object Id { get; set; }

        /// <summary>
        /// Gets Entity Id for API Callling. For Instagram Spredfast API accepts entity_id for twitter it accepts id
        /// </summary>
        public abstract string ApiEntityId { get; }

        public abstract DateTimeOffset Created { get; set; }

        public abstract SocialType SocialType { get; }

        public abstract string AvailableText { get; }

    }

    public enum SocialType
    {
        Twitter,
        Instagram,
    }   
}


Below is the trimmed version of twitter json and c# class



using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System.Globalization;

namespace NRL.Shared.Models.Social.Twitter
{
    public class TwitterPost : SocialPost
    {
        private DateTimeOffset? createdDate = null;

        public override string ApiEntityId => Id_str;

        public override SocialType SocialType => SocialType.Twitter;

        public override DateTimeOffset Created
        {
            get
            {
                if (!createdDate.HasValue)
                {
                    return DateTimeOffset.ParseExact(Created_at, "ddd MMM dd HH:mm:ss K yyyy", new CultureInfo("en-AU"));
                }

                return createdDate.Value;
            }

            set
            {
                createdDate = value;
            }
        }

        public override string AvailableText
        {
            get
            {
                return string.IsNullOrWhiteSpace(Full_Text) ? Text ?? string.Empty : Full_Text;
            }
        }

        public string Created_at { get; set; }

        public string Id_str { get; set; }

        public string Full_Text { get; set; }

        public string Text { get; set; }

        public bool Truncated { get; set; }

        public Entities Entities { get; set; }

        public User User { get; set; }
    }

    public class Medium
    {
        public string Media_url { get; set; }
    }

    public class User
    {
        public string Screen_name { get; set; }
    }

    public class Entities
    {
        public Medium[] Media { get; set; }
    }
}


Below is the trimmed version of instagram json and c# class

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace NRL.Shared.Models.Social.Instagram
{
    public class InstagramPost : SocialPost
    {
        private DateTimeOffset? createdDate = null;

        public User User { get; set; }

        public Images Images { get; set; }

        public string Link { get; set; }

        public override string ApiEntityId => Entity_id;

        public override SocialType SocialType => SocialType.Instagram;

        public override DateTimeOffset Created
        {
            get
            {
                if (!createdDate.HasValue)
                {
                    return DateTimeOffset.FromUnixTimeSeconds(Convert.ToInt64(Created_Time));
                }

                return createdDate.Value;
            }

            set
            {
                createdDate = value;
            }
        }

        public override string AvailableText
        {
            get
            {
                return string.IsNullOrWhiteSpace(Caption?.Text) ? Caption.Text ?? string.Empty : Caption.Text;
            }
        }

        public string Created_Time { get; set; }

        public Caption Caption { get; set; }
    }

    public class User
    {
        public string Username { get; set; }
    }

    public class Image
    {
        public string Url { get; set; }
    }

    public class Images
    {
        public Image Standard_Resolution { get; set; }
    }

    public class Caption
    {
        public string Text { get; set; }
    }
}


We would now need twitter and instagram content class for EPiServer. EPiServer will need ContentFolder and Content Class to show in edit section therefore, we would need to convert TwitterPost and InstagramPost class to Twitter and Instagram Content Class.

SocialContent Base Class
using EPiServer.Core;
using EPiServer.DataAnnotations;
using EPiServer.Shell.ObjectEditing;
using NRL.Web.Business.Models.Pages;
using NRL.Web.Business.Selection;
using System.ComponentModel.DataAnnotations;
using System;
using NRL.Shared.Models.Social;

namespace NRL.Web.Business.Models.Social
{
    [ContentType(GUID = "B2E4619D-9826-4D07-ADF7-9949E89CA6B8")]
    public abstract class SocialContent : ContentBase
    {
        [Display(Name= "Social Type")]
        [SelectOne(SelectionFactoryType = typeof(EnumSelectionFactory<SocialType>))]
        public virtual SocialType SocialType { get; set; }

        [Display(Name = "Id")]
        public virtual string Id { get; set; }

        public abstract ContentPageType ContentPageType { get; }

        public abstract DateTime Published { get; }

        public abstract string GetImageUrl();

        public abstract int GetLength();

        public abstract string GetSummary();

        public abstract string GetTopic();
    }

    public enum ContentType
    {
        ContentFolder,
        DateContentFolder,
        TwitterContent,
        InstagramContent,
    }
}


TwitterContent Class

      [ContentType(GUID = "E4B96F8A-7DFF-41E0-B42B-D4C36337DD80", DisplayName = "Twitter Content", AvailableInEditMode = false)]
    public class TwitterContent : SocialContent
    {
        [Ignore]
        public TwitterPost Post { get; set; }

        public override ContentPageType ContentPageType => ContentPageType.Social;

        public override DateTime Published => Post.Created.DateTime;

        public override string GetImageUrl()
        {
            var image = Post.Entities?.Media?.FirstOrDefault();
            return Business.Helpers.UrlHelper.ImageUrl(image?.Media_url);
        }

        public override int GetLength()
        {
            return Post.AvailableText?.Length ?? 0;
        }

        public override string GetSummary()
        {
            return Post.AvailableText?.SafeSubString(0, 100);
        }

        public override string GetTopic()
        {
            return "Twitter";
        }
    }



InstagramContent Class

  [ContentType(GUID = "0B25DF14-285F-4647-B1B5-262E952C1781", DisplayName = "Instagram Content", AvailableInEditMode = false)]
    public class InstagramContent : SocialContent
    {
        [Ignore]
        public InstagramPost Post { get; set; }

        public override ContentPageType ContentPageType => ContentPageType.Social;

        public override DateTime Published => Post.Created.DateTime;

        public override string GetImageUrl()
        {
            var image = Post.Images?.Standard_Resolution?.Url;
            return Business.Helpers.UrlHelper.ImageUrl(image);
        }

        public override int GetLength()
        {
            return Post.Caption?.Text?.Length ?? 0;
        }

        public override string GetSummary()
        {
            return Post.Caption?.Text?.SafeSubString(0, 100);
        }

        public override string GetTopic()
        {
            return "Instagram";
        }
    }    



Social FolderContent Class

    [ContentType(GUID = "DE8A22EF-EDD4-4F42-BD8B-4DEFFE3C8244", DisplayName = "Spredfast Folder", AvailableInEditMode = false, GroupName = "Social")]
    public class SocialContentFolder : ContentFolder
    {
        [Ignore]
        public SocialType SocialType { get; set; }
    }



Social Content Provider Class

       public sealed class SocialContentProvider : ContentProvider
    {
        private static readonly object Padlock = new object();
        private static SocialContentProvider instance = null;

        private IdentityMappingService identityMappingService;
        private IContentTypeRepository contentTypeRepository;
        private IContentFactory contentFactory;

        public const string DateFolderFormat = "dd-MM-yyyy";
        public const string Key = "social";
        public const string InstagramKey = "Instagram_Streams";
        public const string TwitterKey = "Twitter_Streams";

        private SocialContentProvider()
        {
            identityMappingService = ServiceLocator.Current.GetInstance<IdentityMappingService>();
            contentTypeRepository = ServiceLocator.Current.GetInstance<IContentTypeRepository>();
            contentFactory = ServiceLocator.Current.GetInstance<IContentFactory>();

            /* Create provider root if not exists
             * Provider root will be different for each site so that they can be cached and view seperately
             * Add configuration settings for entry point and capabilites */
            var socialRoot = GetEntryPoint();
            var providerValues = new NameValueCollection();
            providerValues.Add(ContentProviderElement.EntryPointString, socialRoot.ContentLink.ToString());
            providerValues.Add(ContentProviderElement.CapabilitiesString, "Create, Edit, Delete, Search");

            /* initialize and register the provider */
            Initialize(Key, providerValues);
        }

        public static SocialContentProvider Instance
        {
            get
            {
                lock (Padlock)
                {
                    if (instance == null)
                    {
                        instance = new SocialContentProvider();
                    }

                    return instance;
                }
            }
        }

        public SiteDefinition CurrentDefinition
        {
            get
            {
                return SiteDefinition.Current;
            }
        }

        public List<InstagramPost> InstagramFeeds
        {
            get
            {
                // Load Instagram Json and convert to instagram post
            }
        }

        public List<TwitterPost> TwitterPosts
        {
            get
            {
                // Load Twitter Json and convert to twiter post

            }
        }

       protected override void SetCacheSettings(ContentReference contentReference, IEnumerable<GetChildrenReferenceResult> children, CacheSettings cacheSettings)
        {
            // Set a low cache setting so new items are fetched from data source, but keep the
            // items already fetched for a long time in the cache.
            cacheSettings.SlidingExpiration = System.Web.Caching.Cache.NoSlidingExpiration;
            cacheSettings.AbsoluteExpiration = DateTime.Now.AddMinutes(5);
            
            base.SetCacheSettings(contentReference, children, cacheSettings);
        }

        protected override ContentResolveResult ResolveContent(ContentReference contentLink)
        {
            var mappedItem = identityMappingService.Get(contentLink);
            if (mappedItem == null)
                return null;
            return ResolveContent(mappedItem);
        }

        protected override ContentResolveResult ResolveContent(Guid contentGuid)
        {
            var mappedItem = identityMappingService.Get(contentGuid);
            if (mappedItem == null)
                return null;
            return ResolveContent(mappedItem);
        }

        private IContent LoadContent(MappedIdentity mappedIdentity, ILanguageSelector languageSelector)
        {
            ContentType socialContentType;
            SocialType socialType;
            SetSocialContentType(mappedIdentity.ExternalIdentifier, out socialContentType, out socialType);

            string entityId = null;

            switch (socialContentType)
            {
                case ContentType.DateContentFolder:
                    return CreateFolder(mappedIdentity, socialContentType, mappedIdentity.ExternalIdentifier.Segments[4]);
                case ContentType.ContentFolder:
                    return CreateFolder(mappedIdentity, socialContentType, socialType.ToString());
                case ContentType.TwitterContent:
                    {
                        /* Get twitter entity id */
                        entityId = mappedIdentity.ExternalIdentifier.Segments[4];
                        var twitterPost = TwitterPosts.FirstOrDefault(t => t.ApiEntityId.Equals(entityId));
                        return CreateTwitter(mappedIdentity, socialContentType, twitterPost);
                    }

                case ContentType.InstagramContent:
                    {
                        /* Get instagram  entity id */
                        entityId = mappedIdentity.ExternalIdentifier.Segments[4];
                        var instagramPost = InstagramFeeds.FirstOrDefault(t => t.ApiEntityId.Equals(entityId));
                        return CreateInstagram(mappedIdentity, socialContentType, instagramPost);
                    }
            }

            return null;
        }

        protected override IContent LoadContent(ContentReference contentLink, ILanguageSelector languageSelector)
        {
            MappedIdentity mappedItem = identityMappingService.Get(contentLink);
            if (mappedItem == null)
                return null;

            return LoadContent(mappedItem, languageSelector);
        }

        protected override IList<GetChildrenReferenceResult> LoadChildrenReferencesAndTypes(ContentReference contentLink, string languageID, out bool languageSpecific)
        {
            languageSpecific = false;
            var children = new List<GetChildrenReferenceResult>();

            /* If user is expanding root folder then show tiwtter and instagram folders */
            if (EntryPoint.CompareToIgnoreWorkID(contentLink))
            {
                children.Add(new GetChildrenReferenceResult { ContentLink = identityMappingService.Get(MappedIdentity.ConstructExternalIdentifier(ProviderKey, $"{SocialType.Twitter.ToString()}/{ContentType.ContentFolder.ToString()}/{contentLink.ID}/{CurrentDefinition.Id}/"), true).ContentLink, IsLeafNode = false, ModelType = typeof(SocialContentFolder) });
                children.Add(new GetChildrenReferenceResult { ContentLink = identityMappingService.Get(MappedIdentity.ConstructExternalIdentifier(ProviderKey, $"{SocialType.Instagram.ToString()}/{ContentType.ContentFolder.ToString()}/{contentLink.ID}/{CurrentDefinition.Id}/"), true).ContentLink, IsLeafNode = false, ModelType = typeof(SocialContentFolder) });

                return children;
            }

            var mappedItem = identityMappingService.Get(contentLink);
            if (mappedItem == null)
                return children;

            ContentType socialContentType;
            SocialType socialType;
            SetSocialContentType(mappedItem.ExternalIdentifier, out socialContentType, out socialType);

            switch (socialType)
            {
                case SocialType.Instagram:
                    children.AddRange(GetInstagramChildReference(socialContentType, contentLink, mappedItem));
                    break;
                case SocialType.Twitter:
                    children.AddRange(GetTwitterChildReference(socialContentType, contentLink, mappedItem));
                    break;
            }

            return children;
        }

        public static ContentFolder GetEntryPoint()
        {
            var contentRepository = ServiceLocator.Current.GetInstance<IContentRepository>();
            var folder = contentRepository.GetBySegment(ContentReference.RootPage, Key, LanguageSelector.AutoDetect()) as ContentFolder;
            if (folder == null)
            {
                folder = contentRepository.GetDefault<ContentFolder>(ContentReference.RootPage);
                folder.Name = Key;
                contentRepository.Save(folder, SaveAction.Publish, AccessLevel.NoAccess);
            }

            return folder;
        }

        private IEnumerable<GetChildrenReferenceResult> GetInstagramChildReference(ContentType socialContentType, ContentReference parentContentLink, MappedIdentity mappedItem)
        {
            var children = new List<GetChildrenReferenceResult>();
            var instagramStreams = InstagramFeeds.GroupBy(g => g.Created.ToTimeZone(StateTimeZones.Nsw).Date).Select(s => new
            {
                Date = s.Key,
                Feeds = s.ToList<InstagramPost>()
            });

            switch (socialContentType)
            {
                case ContentType.DateContentFolder:
                    {
                        var dateGroupedInstagrams = instagramStreams.FirstOrDefault(i => i.Date.ToString(DateFolderFormat).Equals(mappedItem.ExternalIdentifier.Segments[4]))?.Feeds;
                        if (dateGroupedInstagrams != null)
                        {
                            children.AddRange(GetChildren(
                            dateGroupedInstagrams,
                            (InstagramPost i) => MappedIdentity.ConstructExternalIdentifier(ProviderKey, $"{SocialType.Instagram.ToString()}/{ContentType.InstagramContent.ToString()}/{parentContentLink.ID}/{i.ApiEntityId.ToString()}"),
                            typeof(InstagramContent),
                            true));
                        }
                    }

                    break;
                case ContentType.ContentFolder:
                    {
                        children.AddRange(GetChildren(
                            instagramStreams.Select(s => s.Date).ToList<DateTime>(),
                            (DateTime date) => MappedIdentity.ConstructExternalIdentifier(ProviderKey, $"{SocialType.Instagram.ToString()}/{ContentType.DateContentFolder.ToString()}/{parentContentLink.ID}/{date.ToString(DateFolderFormat)}"),
                        typeof(SocialContentFolder)));
                    }

                    break;
            }

            return children;
        }

        private IEnumerable<GetChildrenReferenceResult> GetTwitterChildReference(ContentType socialContentType, ContentReference parentContentLink, MappedIdentity mappedItem)
        {
            var children = new List<GetChildrenReferenceResult>();
            var twitterPosts = TwitterPosts.GroupBy(g => g.Created.ToTimeZone(StateTimeZones.Nsw).Date).Select(s => new
            {
                Date = s.Key,
                Posts = s.ToList<TwitterPost>()
            });

            switch (socialContentType)
            {
                case ContentType.DateContentFolder:
                    {
                        var dateGroupedTwitter = twitterPosts.FirstOrDefault(i => i.Date.ToString(DateFolderFormat).Equals(mappedItem.ExternalIdentifier.Segments[4]))?.Posts;
                        if (dateGroupedTwitter != null)
                        {
                            children.AddRange(GetChildren(
                                dateGroupedTwitter,
                                (TwitterPost i) => MappedIdentity.ConstructExternalIdentifier(ProviderKey, $"{SocialType.Twitter.ToString()}/{ContentType.TwitterContent.ToString()}/{parentContentLink.ID}/{i.ApiEntityId.ToString()}"),
                            typeof(TwitterContent),
                            true));
                        }
                    }

                    break;
                case ContentType.ContentFolder:
                    {
                        children.AddRange(GetChildren(
                            twitterPosts.Select(s => s.Date).ToList<DateTime>(),
                            (DateTime date) => MappedIdentity.ConstructExternalIdentifier(ProviderKey, $"{SocialType.Twitter.ToString()}/{ContentType.DateContentFolder.ToString()}/{parentContentLink.ID}/{date.ToString(DateFolderFormat)}"),
                        typeof(SocialContentFolder)));
                    }

                    break;
            }

            return children;
        }

        private IEnumerable<GetChildrenReferenceResult> GetChildren<T>(List<T> inputList, Func<T, Uri> createUniqueUri, Type modelType, bool isLeafNode = false)
        {
           var children = new List<GetChildrenReferenceResult>();
           foreach (var t in inputList)
            {
                var externalIdentifier = identityMappingService.Get(createUniqueUri(t), true);
                children.Add(new GetChildrenReferenceResult
                {
                    ContentLink = externalIdentifier.ContentLink,
                    IsLeafNode = isLeafNode,
                    ModelType = modelType
                });
            }

            return children;
        }

        private void SetSocialContentType(Uri externalIdentifier, out ContentType socialContentType, out SocialType socialType)
        {
            /* Getting social type */
            socialType = (SocialType)Enum.Parse(typeof(SocialType), RemoveEndingSlash(externalIdentifier.Segments[1]), true);

            /* Getting social content type */
            socialContentType = (ContentType)Enum.Parse(typeof(ContentType), RemoveEndingSlash(externalIdentifier.Segments[2]), true);
        }

        private IContent CreateContent(MappedIdentity mappedIdentity, ContentType contentType, Type modelType, string name, DateTime createDateTime)
        {
            return CreateContent(mappedIdentity.ContentLink.ID, mappedIdentity.ContentGuid, int.Parse(RemoveEndingSlash(mappedIdentity.ExternalIdentifier.Segments[3])), contentType, modelType, name, createDateTime);
        }

        private IContent CreateContent(int contentId, Guid contentGuid, int parentContentId, ContentType contentType, Type modelType, string name, DateTime createDateTime)
        {
            /* Find parent */
            var parentLink = EntryPoint;
            /* Getting parent id */
            if (contentType != ContentType.ContentFolder)
                parentLink = new ContentReference(parentContentId, ProviderKey);

            EPiServer.DataAbstraction.ContentType epiContentType = contentTypeRepository.Load(modelType);
            var content = contentFactory.CreateContent(epiContentType);

            content.ContentTypeID = epiContentType.ID;
            content.ParentLink = parentLink;
            content.ContentGuid = contentGuid;
            content.ContentLink = new ContentReference(contentId, ProviderKey);
            content.Name = name;

            var securable = content as IContentSecurable;
            securable.GetContentSecurityDescriptor().AddEntry(new AccessControlEntry(EveryoneRole.RoleName, AccessLevel.Read));

            var versionable = content as IVersionable;
            if (versionable != null)
            {
                versionable.Status = VersionStatus.Published;
                versionable.IsPendingPublish = false;
                versionable.StartPublish = createDateTime.AddDays(-1);
            }

            var changeTrackable = content as IChangeTrackable;
            if (changeTrackable != null)
            {
                changeTrackable.Created = createDateTime;
                changeTrackable.Changed = createDateTime;
                changeTrackable.Saved = createDateTime;
            }

            return content;
        }

        private string RemoveEndingSlash(string virtualPath)
        {
            return !string.IsNullOrEmpty(virtualPath) && virtualPath[virtualPath.Length - 1] == '/' ? virtualPath.Substring(0, virtualPath.Length - 1) : virtualPath;
        }

        private SocialContentFolder CreateFolder(MappedIdentity mappedIdentiy, ContentType contentType, string name)
        {
            var content = CreateContent(mappedIdentiy, contentType, typeof(SocialContentFolder), name, DateTime.Now.ToStatesDateTime(StateTimeZones.Nsw.ToString())) as SocialContentFolder;
            return content;
        }

        private InstagramContent CreateInstagram(int contentId, Guid contentGuid, int parentContentId, ContentType contentType, InstagramPost instagramPost)
        {
            var name = $"@{instagramPost.User.Username}: {instagramPost.Caption?.Text?.SafeSubString(0, 200)}";
            var content = CreateContent(contentId, contentGuid, parentContentId, contentType, typeof(InstagramContent), name, instagramPost.Created.ToTimeZone(StateTimeZones.Nsw).DateTime) as InstagramContent;
            content.Id = instagramPost.ApiEntityId.ToString();
            content.SocialType = SocialType.Instagram;
            content.Post = instagramPost;
            return content;
        }

        private InstagramContent CreateInstagram(MappedIdentity mappedIdentiy, ContentType contentType, InstagramPost instagramPost)
        {
            return CreateInstagram(mappedIdentiy.ContentLink.ID, mappedIdentiy.ContentGuid, int.Parse(RemoveEndingSlash(mappedIdentiy.ExternalIdentifier.Segments[3])), contentType, instagramPost);
        }

        private TwitterContent CreateTwitter(int contentId, Guid contentGuid, int parentContentId, ContentType contentType, TwitterPost twitterPost)
        {
            var name = $"@{twitterPost.User.Screen_name}: {twitterPost.AvailableText.SafeSubString(0, 200)}";
            var content = CreateContent(contentId, contentGuid, parentContentId, contentType, typeof(TwitterContent), name, twitterPost.Created.ToTimeZone(StateTimeZones.Nsw).DateTime) as TwitterContent;
            content.Id = twitterPost.ApiEntityId.ToString();
            content.SocialType = SocialType.Twitter;
            content.Post = twitterPost;
            return content;
        }

        private TwitterContent CreateTwitter(MappedIdentity mappedIdentiy, ContentType contentType, TwitterPost twitterPost)
        {
            return CreateTwitter(mappedIdentiy.ContentLink.ID, mappedIdentiy.ContentGuid, int.Parse(RemoveEndingSlash(mappedIdentiy.ExternalIdentifier.Segments[3])), contentType, twitterPost);
        }

        private ContentResolveResult ResolveContent(MappedIdentity mappedItem)
        {
            ContentType socialContentType;
            SocialType socialType;
            SetSocialContentType(mappedItem.ExternalIdentifier, out socialContentType, out socialType);

            Type type = null;
            switch (socialContentType)
            {
                case ContentType.DateContentFolder:
                case ContentType.ContentFolder:
                    type = typeof(SocialContentFolder);
                    break;
                case ContentType.TwitterContent:
                    type = typeof(TwitterContent);
                    break;
                case ContentType.InstagramContent:
                    type = typeof(InstagramContent);
                    break;
            }

            var contentRefernce = new ContentReference(mappedItem.ContentLink.ID, ProviderKey);
            var contentType = contentTypeRepository.Load(type);
            return new ContentResolveResult()
            {
                ContentLink = contentRefernce,
                UniqueID = mappedItem.ContentGuid,
                ContentUri = ConstructContentUri(contentType.ID, contentRefernce, mappedItem.ContentGuid)
            };
        }
    }



Initializing Content Provider
Registering SocialContentProvider with EPiServer

   [ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
    public class ContentProviderConfig : IInitializableModule
    {
        public void Initialize(InitializationEngine context)
        {
            /* Register provider to episerver this can be done through config as well */
            var providerManager = context.Locate.Advanced.GetInstance<IContentProviderManager>();
            providerManager.ProviderMap.AddProvider(SocialContentProvider.Instance);
        }

        public void Uninitialize(InitializationEngine context) { }
    }    



Social Repository Descriptor
This class is social repository descriptor which tells the social component what is the key and root of the component and its contained and creatable types.

       [ServiceConfiguration(typeof(IContentRepositoryDescriptor))]
    public class SocialRepositoryDescriptor : ContentRepositoryDescriptorBase
    {
        protected Injected<IContentProviderManager> ContentProviderManager { get; set; }

        public override string Key => SocialContentProvider.Key;

        public override string Name => SocialContentProvider.Key;

        public override IEnumerable<ContentReference> Roots { get { return new List<ContentReference> { ContentProviderManager.Service.GetProvider(SocialContentProvider.Key).EntryPoint }; } }

        public override IEnumerable<Type> ContainedTypes { get { return new[] { typeof(TwitterContent), typeof(InstagramContent) }; } }

        public override IEnumerable<Type> MainNavigationTypes { get { return new[] { typeof(ContentFolder), typeof(SocialContentFolder) }; } }

        public override IEnumerable<Type> CreatableTypes { get { return new[] { typeof(TwitterContent), typeof(InstagramContent) }; } }

        public bool ChangeContextOnItemSelection { get { return true; } }
    }


ChangeContextOnItemSelection allows EPiServer to load content item preview on click

Social Component Registering
This class registers the Social Component in asset panel and after this Social Tab will be available in asset pane.

      [Component]
    public class SocialComponent : ComponentDefinitionBase
    {
        public SocialComponent()
            : base("epi-cms/widget/HierarchicalList")
        {
            Categories = new string[] { "content" };
            Title = "Social";
            Description = "All the social for the given site";
            SortOrder = 1000;
            PlugInAreas = new[] { PlugInArea.Assets };
            Settings.Add(new Setting("repositoryKey", SocialContentProvider.Key));
        }
    }



Template Descriptor
We can reuse alloy preview controller to preview the Twitter and Instagram content

    [TemplateDescriptor(
        Inherited = true,
        TemplateTypeCategory = TemplateTypeCategories.MvcController,
        AvailableWithoutTag = false,
        ModelType = typeof(TwitterContent),
        Tags = new[] { RenderingTags.Edit, RenderingTags.Preview })]
    [TemplateDescriptor(
        Inherited = true,
        TemplateTypeCategory = TemplateTypeCategories.MvcController,
        AvailableWithoutTag = false,
        ModelType = typeof(InstagramContent),
        Tags = new[] { RenderingTags.Edit, RenderingTags.Preview })]
    public class SocialCardPreviewController : ActionControllerBase
    {
        public ActionResult Index(SocialContent currentContent)
        {
           var model = new NewsPageViewModel(currentContent));
           return View(model);
        }
    }  



TblMappedIdentity table
This part is tricky as we need to convert our social feed into EPiServer content. We can create unique url and then map that unique url to int id through above mentioned table. So when EPiServer comes back with content link or content link id we can fetch back the unique url and fetch back our social details.

Couple of key notes
In our project we had multiple sites where we wanted to show different folder structure and different content for each sites. However, the content provider is intializes once in lifetime so we can only set one root folder. Having said that we would need to create the sub folder based on the site guid so we can distinguish between different site and fetch content based on current site.

View Trash Content Specific To Site For User in EPiServer

In one of our project we had very strict access rights policy for EPiServer projects.
We had about 16 sites and each of the sites had editor which could not see the content of other sites.

We had proper access rights setup on site hierarchy but Recycle Bin EPiServer sits inside the Root Page. The root page and recycle bin had access to everyone therefore, editors could see the deleted content of other editors. Therefore, we had to come up with some approach to restrict the content in recycle bin to editor of that specific site.

What we decided to create virtual role and make sure content is linked to logged in user site.
As the actual content is physically moved underneath the recycle bin. Therefore, there was no way to find the actual site information. However, before deleting content and moving it to under recycle bin EPiServer saves the actual parent deleted content in Dynamic Data Store named ParentRestoreEntity  and can use IParentRestoreRepository to fetch parent link. You can check the view dbo.VW_EPiParentRestoreStore on database as well.


In order to figure out whether, user has access to deleted content. we will fetch the original parent of deleted content and make sure logged in user has access to that parent content validating that the current logged in user has access to this deleted content as well.

Below is the Virtual Role that we created

 namespace NRL.Web.Business.VirtualRoles
{
    using System.Security.Principal;
    using EPiServer.Core;
    using EPiServer.DataAbstraction;
    using EPiServer.Security;
    using EPiServer.Web;
    using NRL.Shared.Extensions;
    using NRL.Web.Business.Extensions;
    using NRL.Web.Business.Models.Pages;
    using EPiServer.Filters;

    public class SiteTrashRole : VirtualRoleProviderBase
    {
        private IVirtualRoleRepository _repository;
        private IParentRestoreRepository _parentRestoreRepository;
        private ISiteDefinitionResolver _siteDefinitionResolver;

        private const string DefaultRoleName = "SiteTrashRole";
        private static string _roleName;

        public override string Name
        {
            get
            {
                return base.Name ?? "SiteTrashRole";
            }

            set
            {
                base.Name = value;
            }
        }

        /// <summary>Gets or sets gets the name of the role.</summary>
        /// <value>The name of the role.</value>
        public static string RoleName
        {
            get
            {
                return SiteTrashRole._roleName ?? (SiteTrashRole._roleName = "SiteTrashRole");
            }

            set
            {
                SiteTrashRole._roleName = value;
            }
        }

        /// <summary>
        /// Creates a new instance of <see cref="T:EPiServer.Security.CreatorRole" /></summary>
        public SiteTrashRole(IVirtualRoleRepository repository, IParentRestoreRepository parentRestoreRepository, ISiteDefinitionResolver siteDefinitionResolver)
        {
            this.EnableIsInRoleCache = false;
            this.SupportsClaims = false;
            this._repository = repository;
            this._parentRestoreRepository = parentRestoreRepository;
            this._siteDefinitionResolver = siteDefinitionResolver;
        }

        /// <summary>
        /// Determines whether the specified principal is a member of this role.
        /// </summary>
        /// <param name="principal">The principal.</param>
        /// <param name="context">The context.</param>
        /// <returns>
        /// <c>true</c> if the specified principal is in the virtual role; otherwise, <c>false</c>.
        ///     </returns>
        /// <note>
        /// Any implementation of IsInVirtualRole must be fully thread-safe since only one instance of the class
        /// is created and any role checks are made against the same instance.
        /// </note>
        public override bool IsInVirtualRole(IPrincipal principal, object context)
        {
            if (!principal.Identity.IsAuthenticated)
                return false;

            if (context is ContentAccessControlList cacl)
            {
                if (cacl.ContentLink == ContentReference.WasteBasket)
                    return true;

                var originalParentLink = _parentRestoreRepository.GetParentLink(cacl.ContentLink);

                /* Check parent access */
                var parentContent = originalParentLink?.Get<IContent>();

                if (parentContent == null)
                    return false;

                if (parentContent.QueryDistinctAccess(AccessLevel.Read))
                    return true;
            }

            return false;
        }
    }
}



The Recylce bin role setup looked like

  <virtualRoles addClaims="true">
      <providers>
         <add name="SiteTrashRole" type="NRL.Web.Business.VirtualRoles.SiteTrashRole, NRL.Web.Business" />
      </providers>
    </virtualRoles>    



Now, when user opens up the recycle bin then it will see content of site it belonged to.



@Note: We have give only read/change access to editors so they can see the lists. The restore and delete would not work as it requires delete access rights. The problem with given delete access rights is that delete button becomes enabled and then editor would be able to delete content of other sites.
If you try to restore you will get below message

Sunday, June 11, 2017

Drilldown Autosuggest Drop down Properties in EPiServer

In one of our projects we had to create drill down auto suggest drop down for EPiServer properties.
We had three level of drill down based on parent and child relation. We wanted to use the existing auto suggest functionality available and tweak it little bit to fit our requirement.
We decided to write dojo JS to change the functionality for suggestion drop down.

How the Drop down looks like




Selection Model


 public class SelectionModel : SelectItem
    {
        public string PrentId { get; set; }
    }


Selection Query Classes

Competition Selection Query


 [ServiceConfiguration(typeof(ISelectionQuery))]
    public class CompetitionSelectionQuery : ISelectionQuery
    {
        IEnumerable _competitions;

        public CompetitionSelectionQuery()
        {
            _competitions = new List
            {
                new SelectItem
                {
                    Text = "Competition 1",
                    Value = "1"
                },
                 new SelectItem
                {
                    Text = "Competition 2",
                    Value = "2"
                }
            };
        }

        public ISelectItem GetItemByValue(string value)
        {
            return _competitions.FirstOrDefault(s => s.Value.ToString() == value);
        }

        public IEnumerable GetItems(string query)
        {
            return _competitions.Where(s => s.Text.Contains(query));
        }
    }


Season Selection Query


 [ServiceConfiguration(typeof(ISelectionQuery))]
    public class SeasonSelectionQuery : ISelectionQuery
    {
        IEnumerable _seasons;

        public SeasonSelectionQuery()
        {
            _seasons = new List
            {
                new SelectionModel
                {
                    Text = "Season 1",
                    Value = "1",
                    PrentId = "1"
                },
                new SelectionModel
                {
                    Text = "Season 2",
                    Value = "2",
                    PrentId = "1"
                },
                new SelectionModel
                {
                    Text = "Season 1",
                    Value = "3",
                    PrentId = "2"
                },
                new SelectionModel
                {
                    Text = "Season 2",
                    Value = "4",
                    PrentId = "2"
                },
            };
        }

        public ISelectItem GetItemByValue(string value)
        {
            return _seasons.FirstOrDefault(s => s.Value.ToString() == value);
        }

        public IEnumerable GetItems(string query)
        {
            var parent = query.Split(new[] { '-' })[0];
            return _seasons.Where(s => s.Text.Contains(query));
        }
    }


Round Selection Query


 [ServiceConfiguration(typeof(ISelectionQuery))]
    public class RoundSelectionQuery : ISelectionQuery
    {
        IEnumerable _rounds;

        public RoundSelectionQuery()
        {
            _rounds = new List
            {
                new SelectionModel
                {
                    Text = "Round 1",
                    Value = "1",
                    PrentId = "1"
                },
                new SelectionModel
                {
                    Text = "Round 2",
                    Value = "2",
                    PrentId = "1"
                },
                new SelectionModel
                {
                    Text = "Round 1",
                    Value = "3",
                    PrentId = "2"
                },
                new SelectionModel
                {
                    Text = "Round 2",
                    Value = "4",
                    PrentId = "2"
                },
            };
        }

        public ISelectItem GetItemByValue(string value)
        {
            return _rounds.FirstOrDefault(s => s.Value.ToString() == value);
        }

        public IEnumerable GetItems(string query)
        {
            var parent = query.Split(new[] { '-' })[0];
            return _rounds.Where(s => s.Text.Contains(query));
        }
    }


How to define properties in Content


        [Display(Order = 110)]
        [LinkedAutoSelection(typeof(CompetitionTagSelectionQuery), null, null, new[] { "/season/topic", "/round/topic"})]
        public virtual string Competition { get; set; }

        [LinkedAutoSelection(typeof(SeasonTagSelectionQuery), new[] { "competition" }, "/season/topic", new[] { "/round/topic" })]
        [Display(Order = 120)]
        public virtual string Season { get; set; }

        [LinkedAutoSelection(typeof(RoundTagSelectionQuery), new[] { "competition", "season" }, "/round/topic", null)]
        [Display(Order = 130)]
        public virtual string Round { get; set; }



Understanding LinkedAutoSelection Attribute


        [AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
    public class LinkedAutoSelectionAttribute : Attribute, IMetadataAware
    {
        internal Injected ModuleTable { get; set; }
        public Type SelectionFactoryType { get; set; }

        /* Dependent Parents: Child needs to pull data from parents to pass to the auto suggest query
         * We will fetch the parents values based on input name */
        public string[] Parents { get; set; }

        /* The change in value will be published to all the publishers */
        public string[] Publishers { get; set; }

        /* The subscriber will reset the value based on parent selection */
        public string Subscriber { get; set; }

        public LinkedAutoSelectionAttribute(Type selectionFactoryType, string[] parents,  string subscriber, string[] publishers)
        {
            SelectionFactoryType = selectionFactoryType;
            this.Publishers = publishers;
            this.Subscriber = subscriber;
            this.Parents = parents;
        }

        public void OnMetadataCreated(ModelMetadata metadata)
        {
            var extendedMetadata = metadata as ExtendedMetadata;
            if (extendedMetadata == null)
                return;

            extendedMetadata.ClientEditingClass = "nrl/Editors/LinkedSelectionEditor/Editor";
            extendedMetadata.CustomEditorSettings["uiType"] = (object)extendedMetadata.ClientEditingClass;
            extendedMetadata.CustomEditorSettings["uiWrapperType"] = (object)"flyout";
            string format = this.ModuleTable.Service.ResolvePath("Shell", "stores/selectionquery/{0}/");
            extendedMetadata.EditorConfiguration["storeurl"] = (object)string.Format((IFormatProvider)CultureInfo.InvariantCulture, format, new object[1]
            {
                (object) this.SelectionFactoryType.FullName
            });

            extendedMetadata.EditorConfiguration["parents"] = Parents;
            extendedMetadata.EditorConfiguration["publishers"] = Publishers;
            extendedMetadata.EditorConfiguration["subscriber"] = Subscriber;
        }
    }



Adding LinkedSelectionEditor Editor.Js File

Setting up module.config

In module.config the path to Resources/Scripts is defined as Murtaza and
inside Resources/Scripts. I have Editors -> LinkedSelectionEditor -> Editor.js
therefore,  the ClientEditingClass is Murtaza/Editors/ImageContentSelector/Editor




<module clientresourcerelativepath="" tags="EPiServerPublicModulePackage">
  <assemblies>
    <add assembly="XXX">
    <add assembly="XXX">
  </add></add></assemblies>

  <dojo>
    <paths>
      <add name="Murtaza" path="Resources/Scripts">
    </add></paths>
  </dojo>

  <clientmodule>
    <moduledependencies>
      <add dependency="CMS" type="RunAfter">
    </add></moduledependencies>
  </clientmodule>
  
</module>


Editor.Js


define("nrl/Editors/LinkedSelectionEditor/Editor",[
    "dojo/_base/declare",
    "dijit/_Widget",
    "dijit/_TemplatedMixin",
    "epi-cms/_ContentContextMixin",
    "dojo/topic",
    "dojo/_base/array",
     "epi/shell/form/AutoCompleteSelectionEditor"
],
    function (
        declare,
        _Widget,
        _TemplatedMixin,
        _ContentContextMixin,
        topic,
        array,
        AutoCompleteSelectionEditor
    ) {
        return declare([
            _Widget,
            _TemplatedMixin,
            _ContentContextMixin,
            AutoCompleteSelectionEditor,            
        ], {
                /* delimeter : String
                 * Delimeter for value and page name
                 * Competition111 -  NRL(NRL is the name of page)*/
                delimeter: "-",
                oldValue: "",
                
                postMixInProperties: function () {
                    this.inherited(arguments);

                    var that = this;
                    if (this.subscriber) {
                        topic.subscribe(this.subscriber, function () {
                            var found = false;
                            var argument = arguments[0];

                            /* We are in context of subscriber and as we have recieved notification for changed in parent value therefore,
                             * we are going to reset current dropdown
                             * clearing the value */

                            /* Clearing value */
                            that._setValueAttr('', true, '');

                            /* Clearing display value */
                            that._setDisplayedValueAttr('');
                        });
                    }
                },

                _setDisplayedValueAttr: function (/*String*/ label, /*Boolean?*/ priorityChange) {

                    /* If label is setting as '' then we need to verify if textbox exits.
                     * in some of the other functions during life cycle textbox does not exists
                     * After clearing up display value we are not going to call base function as it will start fetching
                     * list from server unnecessary and throws exceptions*/
                    if (label === '') {
                        if (this.textbox)
                          this.textbox.value = label;
                        return;
                    }

                    this.inherited(arguments);
                },

                _selectOption: function (/*DomNode*/ target) {

                    this.inherited(arguments);
                    var that = this;
                    if (this.getValue() != this.oldValue)
                    {
                        /* Publish the change to all pubilshers */
                        array.forEach(this.publishers, function (publisher) {

                            /* Publishing Change
                             * Create key value pair */
                            var keyValue = new Object();
                            keyValue.key = that.subscriber;
                            keyValue.value = that.value;
                            topic.publish(publisher, keyValue);
                        });
                    }
                },

                _setValueAttr: function (/*String*/ value, /*Boolean?*/ priorityChange, /*String?*/ displayedValue, /*item?*/ item) {

                   /* Saving the old value */
                    this.oldValue = this.getValue();
                    this.inherited(arguments);
                },

                _startSearch: function (/*String*/ text) {
                    /* Competition111-Season2017-Round06-{text} */
                    /* Prefixing all the pre-requisite parent values */
                    var prefixQuery = '';

                    /* Fetch values from parents */
                    for (var i = 0; this.parents && this.parents.length > i; i++) {
                        var dependantValues = document.getElementsByName(this.parents[i]);
                        if (dependantValues.length > 0) {
                            prefixQuery += this.removePageName(dependantValues[0].value) + this.delimeter;
                        }
                    }

                    /* Removing the first & last {this.delimeter} character (^\-|\-+$)/g */
                    var regex = new RegExp('(^\\' + this.delimeter + '|\\' + this.delimeter + '+$)', 'g');
                    prefixQuery = prefixQuery.replace(regex, '');
                    prefixQuery = (prefixQuery + this.delimeter + text).replace(regex, '');

                    /* Reassigning search text with all pre-requisite values
                     * Keeping old value to reassign later */
                    var oldText = text;
                    text = prefixQuery;
                    this.inherited(arguments);

                    /* Reassigning old value this property will be used in base class */
                    this._lastInput = oldText;
                },

                /* Remove the page name suffixed after {this.delimeter} character */
                removePageName: function (val)
                {
                    var trimmedValue = val;
                    var typeAndId = trimmedValue.split(this.delimeter);
                    if (typeAndId.length > 0) {
                        trimmedValue = typeAndId[0];
                    }

                    return trimmedValue;
                }
        });
    });


Thursday, May 11, 2017

Enabling Opening Of Image Editor With Single/Double Click For Image Property in EPiServer

In one of our project we had to extend functionality to extend the content selector feature to enable to open up the image editor with single/double. The reason for extending feature is to enable quick access to image editor instead of browsing through right hand panel.


How it is look like


Defining Image Porpety as ContentReference


        [UIHint(UIHint.Image)]
        [Display(Order = 40)]
        public virtual ContentReference Image { get; set; }



Extending Image Property Descriptor


 /// 
    /// Extends functionality of image content selector to open up image editor on single/double click
    /// 
    [EditorDescriptorRegistration(TargetType = typeof(ContentReference), UIHint = UIHint.Image)]
    public class ImageEditorDescriptor : EditorDescriptor
    {
        public override void ModifyMetadata(ExtendedMetadata metadata, IEnumerable attributes)
        {
            metadata.ClientEditingClass = "Murtaza/Editors/ImageContentSelector/Editor";

            base.ModifyMetadata(metadata, attributes);

        }
    }



Defining Javascript class

In module.config the path to Resources/Scripts is defined as Murtaza and
inside Resources/Scripts. I have Editors -> ImageContentSelector -> Editor.js
therefore,  the ClientEditingClass is Murtaza/Editors/ImageContentSelector/Editor


<dojo>
    <paths>
      <add name="Murtaza" path="Resources/Scripts">
    </add></paths>
  </dojo>



Extending Dojo Functionality


define([
    "dojo/_base/declare",
    "dojo/dom-style",
    "dojo/aspect",
    "epi-cms/widget/ContentSelector"
],

/* Extends functionality of image content selector to open up image editor on single/double click */
    function (
        declare,
        domStyle,
        aspect,
        ContentSelector
    ) {
        return declare([ContentSelector], {

            postCreate: function () {
                this.inherited(arguments);

                /* Display node is the attach point of outer div */
                this.displayNode.addEventListener("click", this.openImageEditor.bind(this))
                this.displayNode.addEventListener("dbclick", this.openImageEditor.bind(this))

            },

            _updateDisplayNode: function (content) {

                /* Inherited from _SelectorBase through ContentSelector */
                this.inherited(arguments);

                /* Saving it for later to extract content id to form uri */
                this.content = content;
            },

            openImageEditor: function (e)
            {
                if (this.content && this.content.uri) {
                    var contextParameters = { uri: this.content.uri };
                    dojo.publish("/epi/shell/context/request", [contextParameters, { sender: this }]);
                }
            },

        });
    });



In order to load another content in edit mode we need to send request using dojo.publish function.

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 structure of Dojo Control name is as follows 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", " ");
       }