MediaWizards Grimoire

Dec 21, 2022 - Huw Reddick

Umbraco 10 - Razor Class Library Package

With the release of Umbraco 10 I decided it was about time I had a look at creating a nuget package using a Razor Class Library for all the code, views and assets for the Forum, that was the easy part tongue-out, now I had to actually figure out how to turn it into a package and get everything installed into an Umbraco website.

These are my experiences of creating an Umbraco package for the MediaWiz Forums, using package targets, razor class libraries, static web assets and template views.

 

For a quick introduction to packages and razor class libraries, please read Kevin's excellent post

Umbraco 10 - Razor Class Library Packages Pt1 - DEV Community

So, lets get started. We begin by creating a new project using the Umbraco package template, then add a wwwroot and a Views folder to the project, ending up with a project layout similar to the image.

Next, we need to edit the project file so that it recognizes the wwwroot and Views folders so that our templates will be stored and consumed by the website from our library. The project file should look like the example below.

<Project Sdk="Microsoft.NET.Sdk.Razor">
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
    <AddRazorSupportForMvc>true</AddRazorSupportForMvc>
    <ContentTargetFolders>.</ContentTargetFolders>
    <Product>UmbracoPackage1</Product>
    <PackageId>UmbracoPackage1</PackageId>
    <Title>UmbracoPackage1</Title>
    <Description>...</Description>
    <PackageTags>umbraco plugin package</PackageTags>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Umbraco.Cms.Web.Website" Version="10.0.0" />
    <PackageReference Include="Umbraco.Cms.Web.BackOffice" Version="10.0.0" />
  </ItemGroup>

  <ItemGroup>
    <Content Include="App_Plugins\UmbracoPackage1\**">
      <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
      <CopyToPublishDirectory>Always</CopyToPublishDirectory>
    </Content>
    <None Include="build\**">
      <Pack>true</Pack>
      <PackagePath>buildTransitive</PackagePath>
    </None>
  </ItemGroup>

  <ItemGroup>
    <Folder Include="Views\**" />
    <Folder Include="wwwroot\**" />
  </ItemGroup>
</Project>

The next job is to edit the targets file in the build folder so that it copies the Views and wwwroot files into our webproject, the views in the root of the Views folder need to be copied from the assembly as they need to be registered and used as templates for the forums document types, this only works if the files physically exist in the website's views folder.

It should look something like below.

<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
    <PropertyGroup>
        <UmbracoPackage1ContentFilesPath>$(MSBuildThisFileDirectory)..\App_Plugins\UmbracoPackage1\**\*.*</UmbracoPackage1ContentFilesPath>
        <UmbracoPackage1AssetsFilesPath>$(MSBuildThisFileDirectory)..\staticwebassets\**\*.*</UmbracoPackage1AssetsFilesPath>
        <UmbracoPackage1ViewsFilesPath>$(MSBuildThisFileDirectory)..\views\*.*</UmbracoPackage1ViewsFilesPath>
    </PropertyGroup>
    <!-- This target copies the App_Pluins folder to the correct location in the website -->
    <Target Name="CopyUmbracoPackage1Assets" BeforeTargets="Build">
        <ItemGroup>
            <UmbracoPackage1ContentFiles Include="$(UmbracoPackage1ContentFilesPath)" />
        </ItemGroup>
        <Message Text="Copying UmbracoPackage1 files: $(UmbracoPackage1ContentFilesPath) - #@(UmbracoPackage1ContentFiles->Count()) files" Importance="high" />
        <Copy SourceFiles="@(UmbracoPackage1ContentFiles)" DestinationFiles="@(UmbracoPackage1ContentFiles->'$(MSBuildProjectDirectory)\App_Plugins\UmbracoPackage1\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
    </Target>
    <!-- This target copies the wwwroot folder to the correct location in the website -->
    <Target Name="CopyUmbracoPackage1StaticAssets" BeforeTargets="Build">
      <ItemGroup>
        <UmbracoPackage1AssetsFiles Include="$(UmbracoPackage1AssetsFilesPath)" />
      </ItemGroup>
      <Message Text="Copying UmbracoPackage1 files: $(UmbracoPackage1AssetsFilesPath) - #@(UmbracoPackage1AssetsFiles->Count()) files" Importance="high" />
      <Copy SourceFiles="@(UmbracoPackage1AssetsFiles)" DestinationFiles="@(UmbracoPackage1AssetsFiles->'$(MSBuildProjectDirectory)\wwwroot\%(RecursiveDir)%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
    </Target>    
    <!-- This target copies the Views folder root files to the correct location in the website -->
    <Target Name="CopyUmbracoPackage1Views" BeforeTargets="Build">
      <ItemGroup>
        <UmbracoPackage1Views Include="$(UmbracoPackage1ViewsFilesPath)" />
      </ItemGroup>
      <Message Text="Copying UmbracoPackage1 files: $(UmbracoPackage1ViewsFilesPath) - #@(UmbracoPackage1Views->Count()) files" Importance="high" />
      <Copy SourceFiles="@(UmbracoPackage1Views)" DestinationFiles="@(UmbracoPackage1Views->'$(MSBuildProjectDirectory)\views\%(Filename)%(Extension)')" SkipUnchangedFiles="true" />
    </Target>  

    <Target Name="ClearUmbracoPackage1Assets" BeforeTargets="Clean">
        <ItemGroup>
            <UmbracoPackage1Dir Include="$(MSBuildProjectDirectory)\App_Plugins\UmbracoPackage1\" />
        </ItemGroup>
        <Message Text="Clear old UmbracoPackage1 data" Importance="high" />
        <RemoveDir Directories="@(UmbracoPackage1Dir)" />
    </Target>
</Project>

So far so good, we can now add our template views, CSS and JavaScript files to our package and they will get copied to the correct folders in our website 

Now for the next piece of the puzzle, how do we create the document types required by the forum pages. For this we will need the original package.xml file (the ones created in Umbraco if you create a package file). Create a subfolder in the project called Migrations and place the package.xml (or .zip) file inside.

The file should be marked as an embedded resource.

File properties, Embedded resource

To import the package we need to add a couple of classes to the migrations folder, we will start with the a PackageMigrationBase class.

    public class ImportPackageXmlMigration : PackageMigrationBase
    {

        public ImportPackageXmlMigration(IPackagingService packagingService, IMediaService mediaService, MediaFileManager mediaFileManager, MediaUrlGeneratorCollection mediaUrlGenerators, IShortStringHelper shortStringHelper, IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, IMigrationContext context, IOptions<PackageMigrationSettings> packageMigrationsSettings) : base(packagingService, mediaService, mediaFileManager, mediaUrlGenerators, shortStringHelper, contentTypeBaseServiceProvider, context, packageMigrationsSettings)
        {

        }

/*
        This constructor is deprecated so don't use it
        public ImportPackageXmlMigration(IPackagingService packagingService, IMediaService mediaService, MediaFileManager mediaFileManager, MediaUrlGeneratorCollection mediaUrlGenerators, IShortStringHelper shortStringHelper, IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, IMigrationContext context) : base(packagingService, mediaService, mediaFileManager, mediaUrlGenerators, shortStringHelper, contentTypeBaseServiceProvider, context)
        {
        }
*/

        protected override void Migrate()
        {

            ImportPackage.FromEmbeddedResource<ImportPackageXmlMigration>().Do();
            Context.AddPostMigration<PublishRootBranchPostMigration>();
        }
    }

The above code is a basic implementation which will import everything in your package xml or zip file and register it in the website, to make the import run we also need to add another class, a PackageMigrationPlan class similar to the one below.

    public class MediaWizPackageMigrationPlan : PackageMigrationPlan
    {
        public MediaWizPackageMigrationPlan() : base("MediaWiz Forums")
        {
        }

        protected override void DefinePlan()
        {
            From(String.Empty)
                .To<ImportPackageXmlMigration>(new Guid("87B98517-9A06-457D-9445-59F56CFD1A32"));
        }
    }

The problem I encountered with this approach was that the templates defined in the package would overwrite the ones in the RCL because the package migration runs after the RCL views are copied to the website, removing the template definitions from the package, obviously stopped that, but the templates were not then registered in the backoffice so were consequently not assigned to the document types.

To accomplish this we need to use the IFileService in our ImportPackageXmlMigration class to register our views as templates before the migration plan creates the document types. There is a method in the IFileService which we can use to do this, so I updated the ImportPackageXmlMigration to register the views as templates, passing a null vaue for the content causes Umbraco to use the views we have copied in, passing a value in content will make it create a new view overwriting what we have copied.

    public class ImportPackageXmlMigration : PackageMigrationBase
    {
        private readonly IFileService _fileService;

        /*
            Inject the IFileService 
         */
        public ImportPackageXmlMigration(IPackagingService packagingService, IMediaService mediaService, MediaFileManager mediaFileManager, MediaUrlGeneratorCollection mediaUrlGenerators, IShortStringHelper shortStringHelper, IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, IMigrationContext context, IOptions<PackageMigrationSettings> packageMigrationsSettings
            ,IFileService fileService) : base(packagingService, mediaService, mediaFileManager, mediaUrlGenerators, shortStringHelper, contentTypeBaseServiceProvider, context, packageMigrationsSettings)
        {
            _fileService = fileService;
        }

/*
        This constructor is deprecated so don't use it
        public ImportPackageXmlMigration(IPackagingService packagingService, IMediaService mediaService, MediaFileManager mediaFileManager, MediaUrlGeneratorCollection mediaUrlGenerators, IShortStringHelper shortStringHelper, IContentTypeBaseServiceProvider contentTypeBaseServiceProvider, IMigrationContext context) : base(packagingService, mediaService, mediaFileManager, mediaUrlGenerators, shortStringHelper, contentTypeBaseServiceProvider, context)
        {
        }
*/

        protected override void Migrate()
        {
            //register the views as templates before importing the package
            var master = _fileService.GetTemplate("forumMaster") ?? _fileService.CreateTemplateWithIdentity("ForumMaster", "forumMaster",null);
            var forumTemplates = new[] { "forum", "forumPost", "login", "members", "profile", "register", "reset", "verify", "searchPage", "activeTopics" };
            foreach (var template in forumTemplates)
            {
                var found = _fileService.GetTemplate(template);
                if (found == null)
                {
                    _fileService.CreateTemplateWithIdentity(template.FirstCharToUpper(), template,null,master);
                }
            }
            //Now the templates are registered we can import the package xml, but first lets remove the empty templates
            ImportPackage.FromEmbeddedResource<ImportPackageXmlMigration>().Do();
            Context.AddPostMigration<PublishRootBranchPostMigration>();
        }
    }

The string[] contains a list of the template aliases we want to register.

Now we are getting somewhere, the package now installs, copies the templates, App_Plugins and wwwroot files to the website and then creates the document types and content pages.

To complete the installation for the Forums I just needed to create a few other things before it was ready, for this I just added a post migration class which is called after the package import.

Context.AddPostMigration<PublishRootBranchPostMigration>();

This class just contains a few methods for creating the other bits and pieces need for the Forum, Forum member type, member groups etc. The code is shortened but should give an idea of what can be done here.

    public class PublishRootBranchPostMigration : MigrationBase
    {
        private readonly ILogger<PublishRootBranchPostMigration> _logger;
        private readonly IContentService _contentService;
        private readonly IMemberTypeService _memberTypeService;
        private readonly IDataTypeService _dataTypeService;
        private readonly IShortStringHelper _shortStringHelper;
        private readonly IMemberGroupService _memberGroupService;
        private readonly IContentTypeService _contentTypeService;
        private readonly IExamineManager _examine;
        private readonly IMemberService _memberService;
        private readonly ILocalizationService _localizationService;

        public PublishRootBranchPostMigration(
            ILogger<PublishRootBranchPostMigration> logger,
            IContentService contentService,
            IMigrationContext context,
            IMemberGroupService memberGroupService,
            IMemberTypeService memberTypeService,
            IDataTypeService dataTypeService,
            IShortStringHelper shortStringHelper,
            IExamineManager examine,
            IContentTypeService contentTypeService,
            IMemberService memberService,ILocalizationService localizationService) : base(context)
        {
            _logger = logger;
            _memberGroupService = memberGroupService;
            _memberTypeService = memberTypeService;
            _dataTypeService = dataTypeService;
            _contentService = contentService;
            _shortStringHelper = shortStringHelper;
            _examine = examine;
            _contentTypeService = contentTypeService;
            _memberService = memberService;
            _localizationService = localizationService;
        }

        protected override void Migrate()
        {
            var contentForum = _contentService.GetRootContent().FirstOrDefault(x => x.ContentType.Alias == "forum");
            if (contentForum != null)
            {
                AddForumMemberType();
                AddMemberGroups();
                AddDictionaryItems();
                UpdatePostCounts();
                //Make sure the Forum root has been published
                _contentService.SaveAndPublishBranch(contentForum, true);
            }
            else
            {
                _logger.LogWarning("The Forum is already installed");
            }
        }

        private bool AddForumMemberType()
        {
            ...
        }
        private void AddMemberGroups()
        {
            ...           

        }
        /// <summary>
        /// Updates the postcounts if Forum members alreay exist
        /// </summary>
        /// <returns></returns>
        private long UpdatePostCounts()
        {
            ...

        }
        private void AddDictionaryItems()
        {
            ...

        }

    }

In this blog post I explain how to implement an email validation flow for Member registration.

In part 2 of my Implementing a Forgot password for members I explain how to implement the IMemberMailService to send the reset password email.

How to implement a ForgotPassword process for Umbraco members in Umbraco 9+

Custom views give you complete control over how a Block is rendered in the backoffice and this enables you to give a better representation of the content. In this article I will explain how I created a custom Block view based on the fullcalendar.io javascript library to display events in the backoffice.

These are my experiences of creating an Umbraco package for the MediaWiz Forums, using package targets, razor class libraries, static web assets and template views.

Many thanks go to Kevin Jump and Luuk Peters without whose help I would probably have given up.