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", " ");
       }
 

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.

Thursday, March 30, 2017

.Net Core Entity Framework Seperate Entity and Mapping Class

Follow the below steps to have separate entity and mapping definition for entity framework.

This blog assumes you have prior knowledge of the .Net Entity Framework and you have ApplicationDBContext created already.

First we will have BaseEntity Class


namespace TogetherWeCan.Data.IdentityManagement.Model
{
    public class EntityBase
    {
    }
}  
  

Then, we will have EntityMappingBase class with virtual function accpeting ModelBuilder as a parameter.


using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using System;
using System.Collections.Generic;
using System.Text;

namespace TogetherWeCan.Data.IdentityManagement.Model
{
    public abstract class EntityMappingBase<T> where T : EntityBase
    {

        public virtual void BuildAction(ModelBuilder builder)
        {
            builder.Entity<EntityBase>();
        }

    }
}

We are going to define two Entity Class.

  1. Group
  2. GroupTwo


namespace TogetherWeCan.Data.IdentityManagement.Model
{
    public class Group : EntityBase
    {
        public Guid GroupId { get; set; }
        public string Name { get; set; }
        public DateTime Created { get; set; }
    }
}
namespace TogetherWeCan.Data.IdentityManagement.Model
{
    public class GroupTwo : EntityBase
    {
        public Guid GroupTwoId { get; set; }
        public string Name { get; set; }
        public DateTime Created { get; set; }
    }
}  
  

Next we will define two mapping for Group and GroupTwo entity class.

  1. GroupMapping
  2. GroupTwoMapping


namespace TogetherWeCan.Data.IdentityManagement
{
    public class GroupMapping : EntityMappingBase<EntityBase>
    {
        public override void BuildAction(ModelBuilder builder)
        {
            var entityBuilder = builder.Entity<Group>();
            entityBuilder.HasKey(p => p.GroupId);
            entityBuilder.Property(p => p.Name).IsRequired();

        }

    }
}
  
namespace TogetherWeCan.Data.IdentityManagement
{
    public class GroupTwoMapping : EntityMappingBase<EntityBase>
    {
        public override void BuildAction(ModelBuilder builder)
        {
            var entityBuilder = builder.Entity<GroupTwo>();
            entityBuilder.HasKey(p => p.GroupTwoId);
            entityBuilder.Property(p => p.Name).IsRequired();

        }

    }
}  


Now we will have ApplicationDBContext class to initialize above mapping
In order to get the runtime assembly and class i have used below packages

For RuntimeEnvironment.GetRuntimeIdentifier() I have used  Microsoft.DotNet.InternalAbstractions;

For  DependencyContext.Default.GetRuntimeAssemblyNames(runtimeId).Where(w => w.FullName.Contains("TogetherWeCan.Data")); I have used Microsoft.Extensions.DependencyModel;



using System;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using TogetherWeCan.Data.IdentityManagement;
using Microsoft.EntityFrameworkCore;
using System.Reflection;
using System.Linq;
using TogetherWeCan.Data.IdentityManagement.Model;
using Microsoft.DotNet.InternalAbstractions;
using Microsoft.Extensions.DependencyModel;

namespace TogetherWeCan.Data
{
    public class ApplicationIdentityDBContext : IdentityDbContext<ApplicationUser>
    {
        public ApplicationIdentityDBContext(DbContextOptions<ApplicationIdentityDBContext> options) : base(options)
        {
            
        }
        public DbSet<Group> Group { get; set; }
        public DbSet<GroupTwo> GroupTwo { get; set; }

        protected override void OnModelCreating(ModelBuilder builder)
        {
            var runtimeId = RuntimeEnvironment.GetRuntimeIdentifier();
            var assemblies = DependencyContext.Default.GetRuntimeAssemblyNames(runtimeId).Where(w => w.FullName.Contains("TogetherWeCan.Data"));

            foreach (var assembly in assemblies)

            {
                Assembly newtonsoftJson = Assembly.Load(assembly);
                foreach (var t in newtonsoftJson.GetTypes())
                {
                    var btyp = t.GetTypeInfo().BaseType;
                    if (btyp != null && btyp.IsConstructedGenericType && btyp.GetGenericTypeDefinition() == typeof(EntityMappingBase<>))
                    {
                        var entityBaseMapping = Activator.CreateInstance(t) as EntityMappingBase<EntityBase>;
                        entityBaseMapping.BuildAction(builder);
                    }

                }

            }
   

            base.OnModelCreating(builder);
        }

    }
}
  
  


Testing

For testing in Startup.cs inject ApplicationDBContext in Configure function and use context.Database.Migrate(); to force entity framework run the migration. Don't forget to add migrations.



using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using TogetherWeCan.Data;
using TogetherWeCan.Data.IdentityManagement;

namespace TogetherWeCan
{
    public class Startup
    {
        public Startup(IHostingEnvironment env)
        {
            var builder = new ConfigurationBuilder()
                .SetBasePath(env.ContentRootPath)
                .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
                .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true)
                .AddEnvironmentVariables();
            Configuration = builder.Build();
        }

        public IConfigurationRoot Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            // Add framework services.
            services.AddDbContext<ApplicationIdentityDBContext>(options => {
                options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"));
                });


            services.AddIdentity<ApplicationUser, IdentityRole>()
       .AddEntityFrameworkStores<ApplicationIdentityDBContext>()
       .AddDefaultTokenProviders();

            // Add framework services.
            services.AddMvc();
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory, ApplicationIdentityDBContext context)
        {
            loggerFactory.AddConsole(Configuration.GetSection("Logging"));
            loggerFactory.AddDebug();
            context.Database.Migrate();

            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
                app.UseBrowserLink();
            }
            else
            {
                app.UseExceptionHandler("/Home/Error");
            }

            app.UseStaticFiles();

            app.UseMvc(routes =>
            {
                routes.MapRoute(
                    name: "default",
                    template: "{controller=Home}/{action=Index}/{id?}");
            });
        }
    }
}