Posted in: Comments

Sometimes you have content, e.g. pages, that's necessary for the website to run. This could be the startpage, news archive page, search page and so on. Let's call them 'system content'.

Now, if you want to prevent your editors from accidentally deleting these pages you could change the permissions to these pages. The downside with using the regular content permissions is that you have to stop the inheritance of the permissions, and not just that, you also need to reset them again for every child page that you want the editors to able to delete.

This gets unmanageable after a while.

So what can we do instead? Well, we could hook in to the content events in the content repository and then stop them from being deleted.

First we define an interface to mark all content types that should not be deleted.

namespace DV.ContentTypes.Interfaces
{
    using EPiServer.Core;

    /// <summary>
    ///     Decorate content types with this interface to classify them
    ///     as system content. System content should not be deleted by editors e.g..
    ///     We will stop editors from deleting these content items unless they have
    ///     access to that function. Please see <see cref="DV.DeleteSystemContentPermissions"/>
    ///     and  <see cref="DV.SystemContentInitialization"/>.
    /// </summary>
    public interface ISystemContent : IContent
    {
    }
}

Then we decorate our content types.

namespace DV.ContentTypes.Pages
{
    [ContentType]
    public class HomePage : PageData, ISystemContent
    {
    }
}

But hey, wouldn't it be nice if we could delete them anyway, in like special cases? Let's create some special permissions, Permission to Functions to the rescue.

namespace DV
{
    using EPiServer.DataAnnotations;
    using EPiServer.Security;

    [PermissionTypes]
    public static class DeleteSystemContentPermissions
    {
        static DeleteSystemContentPermissions()
        {
            DeleteContent = new PermissionType(nameof(DeleteSystemContentPermissions), nameof(DeleteContent));
            DeleteLanguageVersion = new PermissionType(nameof(DeleteSystemContentPermissions), nameof(DeleteLanguageVersion));
        }

        public static PermissionType DeleteContent { get; private set; }

        public static PermissionType DeleteLanguageVersion { get; private set; }
    }
}

Follow the guide in the link above to give the permissions more meaningfull descriptions by also creating translations.

Now we can create an initializable module that will actually prevent editors from deleting 'system content' or allow them if they have enough permissions.

namespace DV
{
    using EPiServer;
    using EPiServer.Core;
    using EPiServer.Framework;
    using EPiServer.Framework.Initialization;
    using EPiServer.Security;
    using EPiServer.ServiceLocation;

    [InitializableModule]
    [ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
    public class SystemContentInitialization : IInitializableModule
    {
        public void Initialize(InitializationEngine context)
        {
            var contentEvents = ServiceLocator.Current.GetInstance<IContentEvents>();

            contentEvents.MovingContent += this.MovingContent;
            contentEvents.DeletingContentLanguage += this.DeletingContentLanguage;
        }

        public void Uninitialize(InitializationEngine context)
        {
            var contentEvents = ServiceLocator.Current.GetInstance<IContentEvents>();

            contentEvents.MovingContent -= this.MovingContent;
            contentEvents.DeletingContentLanguage -= this.DeletingContentLanguage;
        }

        private void MovingContent(object sender, ContentEventArgs e)
        {
            if (e.Content is ISystemContent == false)
            {
                return;
            }

            if (e.TargetLink.Equals(ContentReference.WasteBasket, true) == false)
            {
                return;
            }

            bool hasPermission = PrincipalInfo.Current.IsPermitted(DeleteSystemContentPermissions.DeleteContent);

            if (hasPermission == false)
            {
                e.CancelAction = true;
                e.CancelReason = "You're not allowed to delete this content. You need permission to this function.";
            }
        }

        private void DeletingContentLanguage(object sender, ContentEventArgs e)
        {
            if (e.Content is ISystemContent == false)
            {
                return;
            }

            bool hasPermission = PrincipalInfo.Current.IsPermitted(DeleteSystemContentPermissions.DeleteLanguageVersion);

            if (hasPermission == false)
            {
                e.CancelAction = true;
                e.CancelReason = "You're not allowed to delete this language. You need permission to this function.";
            }
        }
    }
}

The Version gadget will unfortunately not display our CancelReason message when deleting a language version, but if you try to delete the content (master language), then our message will be displayed. I you're using the Language add-on, then the add-on will just throw an error when you delete a language. I wish the Language add-on would handle this better, but hey, it will at least stop the editors from deleting the language version.

Posted in: Comments

Screenshot from Episerver editing UISince I started working with Episerver back in 2008 I've been missing better taxonomy support, that and a "bucket" feature where we can put content that doesn't fit into a content tree, but in this post I will just cover taxonomy (buckets might be my next challenge though).

If you're like me, you've probably always wondered why you need admin access to just administer categories and why it's so cumbersome to translate them. Well, you don't have to anymore! Head over to GitHub and check my new project out at https://github.com/JohanPetersson/episerver-taxonomy.

You'll find more information at GitHub, but in short this Add-on gives editors a new UI to manage taxonomy and us, developers, a new way to define different types of taxonomy.

We can define taxonomy by inheriting from TaxonomyData:

using Dodavinkeln.Taxonomy.Core;
using EPiServer.DataAnnotations;

[ContentType(GUID = "974ebaf3-c6fc-4332-a809-344b7e372f21")]
public class CategoryData : TaxonomyData
{
}

And then add taxonomy properties to content types:

[Taxonomy]
[Display(Name = "News category")]
public virtual ContentReference NewsCategory { get; set; }

The Add-on is not release in a public feed yet, I would like to have some feedback and implement a few more features before releasing 1.0. So please check it out and leave comments here or on GitHub.

Posted in: Comments

Lets say you want to give your editors the possibility to select a user (username) in a property so we can display e.g. a byline in an article. The simple solution is to just add a string property to your page type, but then the editor need to enter the username manually. Instead we can use the AutoSuggestSelection attribute, then we only need to implement an ISelectionQuery.

Fortunately there is an API in Episerver we can use to query for users, IQueryableNotificationUsers, but keep in mind that the API is internal and may change without notice.

First we need to implement our query:

namespace DV
{
    using System.Collections.Generic;
    using System.Linq;
    using System.Threading.Tasks;
    using EPiServer.Notification;
    using EPiServer.ServiceLocation;
    using EPiServer.Shell.ObjectEditing;

    [ServiceConfiguration(typeof(ISelectionQuery))]
    public class UserSelectionQuery : ISelectionQuery
    {
        private readonly IQueryableNotificationUsers userRepository;

        public UserSelectionQuery(IQueryableNotificationUsers userRepository)
        {
            this.userRepository = userRepository;
        }

        public ISelectItem GetItemByValue(string value)
        {
            var task = Task.Run(async () => await this.userRepository.FindAsync(value, 0, 1));

            task.Wait();

            if (task.Result.TotalCount == 0)
            {
                return null;
            }

            var user = task.Result.PagedResult.Single();

            return new SelectItem
            {
                Text = $"{user.DisplayName} ({user.UserName})",
                Value = user.UserName
            };
        }

        public IEnumerable<ISelectItem> GetItems(string query)
        {
            var task = Task.Run(async () => await this.userRepository.FindAsync(query, 0, 10));

            task.Wait();

            if (task.Result.TotalCount == 0)
            {
                return Enumerable.Empty<ISelectItem>();
            }

            return task.Result.PagedResult.Select(user => new SelectItem
            {
                Text = $"{user.DisplayName} ({user.UserName})",
                Value = user.UserName
            });
        }
    }
}

Then we can use it in the attribute:

[AutoSuggestSelection(typeof(UserSelectionQuery))]
[Display(Name = "Published by")]
public virtual string PublishedBy { get; set; }

Hope this helps.

Posted in: Comments

I couldn't find constants for all Episerver action icons located here http://ux.episerver.com/ anywhere in Episerver or on the Internet. So I thought I would share those with you. These are really useful if you e.g. are using this solution.

public static class ContentIcons
{
    public const string Clock = "epi-iconClock";
    public const string Checkmark = "epi-iconCheckmark";
    public const string Stop = "epi-iconStop";
    public const string Versions = "epi-iconVersions";
    public const string Revert = "epi-iconRevert";
    public const string Undo = "epi-iconUndo";
    public const string Redo = "epi-iconRedo";
    public const string Pen = "epi-iconPen";
    public const string PenDisabled = "epi-iconPenDisabled";
    public const string DuplicatePage = "epi-iconDuplicatePage";
    public const string Primary = "epi-iconPrimary";
    public const string Trash = "epi-iconTrash";
    public const string Users = "epi-iconUsers";
    public const string Search = "epi-iconSearch";
    public const string Plus = "epi-iconPlus";
    public const string Minus = "epi-iconMinus";
    public const string Star = "epi-iconStar";
    public const string Settings = "epi-iconSettings";
    public const string Lock = "epi-iconLock";
    public const string Pin = "epi-iconPin";
    public const string Page = "epi-iconPage";
    public const string Folder = "epi-iconFolder";
    public const string SharedBlock = "epi-iconSharedBlock";
    public const string Up = "epi-iconUp";
    public const string Down = "epi-iconDown";
    public const string Reload = "epi-iconReload";
    public const string Share = "epi-iconShare";
    public const string Download = "epi-iconDownload";
    public const string DnD = "epi-iconDnD";
    public const string ContextMenu = "epi-iconContextMenu";
    public const string Rename = "epi-iconRename";
    public const string Left = "epi-iconLeft";
    public const string Right = "epi-iconRight";
    public const string User = "epi-iconUser";
    public const string Hidden = "epi-iconHidden";
    public const string NewWindow = "epi-iconNewWindow";
    public const string Catalog = "epi-iconCatalog";
    public const string Category = "epi-iconCategory";
    public const string Product = "epi-iconProduct";
    public const string SKU = "epi-iconSKU";
    public const string Package = "epi-iconPackage";
    public const string Bundle = "epi-iconBundle";
    public const string Boxes = "epi-iconBoxes";
    public const string References = "epi-iconReferences";
    public const string Bubble = "epi-iconBubble";
    public const string Location = "epi-iconLocation";
    public const string Mail = "epi-iconMail";
    public const string Telephone = "epi-iconTelephone";
    public const string Website = "epi-iconWebsite";
    public const string Link = "epi-iconLink";
    public const string Upload = "epi-iconUpload";
    public const string Error = "epi-iconError";
    public const string Warning = "epi-iconWarning";
    public const string Info = "epi-iconInfo";
    public const string Pricing = "epi-iconPricing";
    public const string Cut = "epi-iconCut";
    public const string Copy = "epi-iconCopy";
    public const string Paste = "epi-iconPaste";
    public const string Detach = "epi-iconDetach";
    public const string List = "epi-iconList";
    public const string Thumbnails = "epi-iconThumbnails";
    public const string Tiles = "epi-iconTiles";
    public const string Project = "epi-iconProject";
    public const string Published = "epi-iconPublished";
    public const string PreviouslyPublished = "epi-iconPreviouslyPublished";
    public const string Truck = "epi-iconTruck";
    public const string Cart = "epi-iconCart";
    public const string Sort = "epi-iconSort";
    public const string Campaign = "epi-iconCampaign";
    public const string PublishProject = "epi-iconPublishProject";
    public const string Promotion = "epi-iconPromotion";
    public const string Layout = "epi-iconLayout";
    public const string SortAscending = "epi-iconSortAscending";
    public const string SortDescending = "epi-iconSortDescending";
    public const string Edited = "epi-iconEdited";
    public const string Bell = "epi-iconBell";
}

  

Posted in: Comments

This topic comes up from time to time in the forums. I don’t find some of the solutions given completely adequate. They usually just makes the URL absolute by appending the current context’s host name, if present. What we should do instead, is to load the SiteDefinition for the content passed in to our method and then load the host from that definition. The site definition can contain multiple hosts and different kinds as well, e.g. primary host.

This method also works for MediaData.

public static string ContentExternalUrl(this ContentReference contentLink, CultureInfo contentLanguage, bool absoluteUrl)
{
    var result = ServiceLocator.Current.GetInstance<UrlResolver>().GetUrl(
        contentLink,
        contentLanguage.Name,
        new VirtualPathArguments
        {
            ContextMode = ContextMode.Default,
            ForceCanonical = absoluteUrl
        });

    // HACK: Temprorary fix until GetUrl and ForceCanonical works as expected,
    // i.e returning an absolute URL even if there is a HTTP context that matches the content's site definition and host.
    if (absoluteUrl)
    {
        Uri relativeUri;

        if (Uri.TryCreate(result, UriKind.RelativeOrAbsolute, out relativeUri))
        {
            if (!relativeUri.IsAbsoluteUri)
            {
                var siteDefinitionResolver = ServiceLocator.Current.GetInstance<SiteDefinitionResolver>();
                var siteDefinition = siteDefinitionResolver.GetDefinitionForContent(contentLink, true, true);
                var hosts = siteDefinition.GetHosts(contentLanguage, true).ToList();

                var host = hosts.FirstOrDefault(h => h.Type == HostDefinitionType.Primary) 
                    ?? hosts.FirstOrDefault(h => h.Type == HostDefinitionType.Undefined);

                var basetUri = siteDefinition.SiteUrl;

                if (host != null && host.Name.Equals("*") == false)
                {
                    // Try to create a new base URI from the host with the site's URI scheme. Name should be a valid
                    // authority, i.e. have a port number if it differs from the URI scheme's default port number.
                    Uri.TryCreate(siteDefinition.SiteUrl.Scheme + "://" + host.Name, UriKind.Absolute, out basetUri);
                }

                var absoluteUri = new Uri(basetUri, relativeUri);

                return absoluteUri.AbsoluteUri;
            }
        }
    }

    return result;
}